From 4bc7ffd0ba088b3a2c24b84889fe425f8d2368bb Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Sun, 28 Aug 2022 17:23:19 +0200 Subject: [PATCH] Multiple e-mail addresses, seperate recovery address --- check_migrations.py | 5 +- tests/test_api.py | 5 +- tests/test_invite.py | 4 +- tests/test_oauth2.py | 2 +- tests/test_role.py | 8 +- tests/test_selfservice.py | 282 ++- tests/test_services.py | 28 +- tests/test_signup.py | 6 +- tests/test_user.py | 211 +- tests/utils.py | 4 +- uffd/commands/user.py | 5 +- .../9f824f61d8ac_use_utc_for_datetime.py | 2 +- .../b273d7fdaa25_multiple_email_addresses.py | 141 ++ uffd/models/__init__.py | 9 +- uffd/models/selfservice.py | 16 - uffd/models/service.py | 8 +- uffd/models/signup.py | 6 +- uffd/models/user.py | 140 +- .../selfservice/mailverification.mail.txt | 2 +- uffd/templates/selfservice/self.html | 81 +- uffd/templates/user/show.html | 93 +- uffd/translations/de/LC_MESSAGES/messages.mo | Bin 36213 -> 38331 bytes uffd/translations/de/LC_MESSAGES/messages.po | 1785 +++++++++-------- uffd/views/api.py | 1 + uffd/views/selfservice.py | 118 +- uffd/views/user.py | 57 +- 26 files changed, 1892 insertions(+), 1127 deletions(-) create mode 100644 uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py diff --git a/check_migrations.py b/check_migrations.py index 1a9773ea..eb8d8bc0 100755 --- a/check_migrations.py +++ b/check_migrations.py @@ -8,7 +8,7 @@ import flask_migrate from uffd import create_app, db from uffd.models import ( - User, Group, + User, UserEmail, Group, RecoveryCodeMethod, TOTPMethod, WebauthnMethod, Role, RoleGroup, Signup, @@ -16,7 +16,7 @@ from uffd.models import ( DeviceLoginConfirmation, Service, OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, - PasswordToken, MailToken, + PasswordToken, ) def run_test(dburi, revision): @@ -73,7 +73,6 @@ def run_test(dburi, revision): db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now())) db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)])) db.session.add(PasswordToken(user=user)) - db.session.add(MailToken(user=user, newmail='test@example.com')) db.session.commit() flask_migrate.downgrade(revision=revision) flask_migrate.upgrade(revision='head') diff --git a/tests/test_api.py b/tests/test_api.py index 7734da84..9200fc89 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -266,10 +266,9 @@ class TestAPIRemailerResolve(UffdTestCase): def test(self): self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' service = Service.query.filter_by(name='service2').one() - user = self.get_user() - r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(service.id, user.id)), headers=[basic_auth('test', 'test')], follow_redirects=True) + r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(service.id, self.get_user().id)), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) - self.assertEqual(r.json, {'address': user.mail}) + self.assertEqual(r.json, {'address': self.get_user().primary_email.address}) r = self.client.get(path=url_for('api.resolve_remailer', orig_address='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) self.assertEqual(r.json, {'address': None}) diff --git a/tests/test_invite.py b/tests/test_invite.py index 93dea192..d9bc44db 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -191,7 +191,7 @@ class TestInviteSignupModel(UffdTestCase): self.assertTrue(invite.used) self.assertEqual(user.loginname, 'newuser') self.assertEqual(user.displayname, 'New User') - self.assertEqual(user.mail, 'test@example.com') + self.assertEqual(user.primary_email.address, 'test@example.com') self.assertEqual(signup.user, user) self.assertIn(base_role, user.roles_effective) self.assertIn(role1, user.roles) @@ -219,7 +219,7 @@ class TestInviteSignupModel(UffdTestCase): self.assertTrue(invite.used) self.assertEqual(user.loginname, 'newuser') self.assertEqual(user.displayname, 'New User') - self.assertEqual(user.mail, 'test@example.com') + self.assertEqual(user.primary_email.address, 'test@example.com') self.assertEqual(signup.user, user) self.assertIn(base_role, user.roles_effective) self.assertEqual(len(user.roles_effective), 1) diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 990e2c13..1dea4074 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -39,7 +39,7 @@ class TestViews(UffdTestCase): self.assertEqual(r.json['id'], user.unix_uid) self.assertEqual(r.json['name'], user.displayname) self.assertEqual(r.json['nickname'], user.loginname) - self.assertEqual(r.json['email'], mail or user.mail) + self.assertEqual(r.json['email'], mail or user.primary_email.address) self.assertTrue(r.json.get('groups')) def test_authorization(self): diff --git a/tests/test_role.py b/tests/test_role.py index c02b862c..3283d273 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -32,7 +32,7 @@ class TestPrimitives(unittest.TestCase): class TestUserRoleAttributes(UffdTestCase): def test_roles_effective(self): - db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service')) + db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service')) db.session.commit() user = self.get_user() service_user = User.query.filter_by(loginname='service').one_or_none() @@ -82,7 +82,7 @@ class TestUserRoleAttributes(UffdTestCase): class TestRoleModel(UffdTestCase): def test_members_effective(self): - db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service')) + db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service')) db.session.commit() user1 = self.get_user() user2 = self.get_admin() @@ -229,7 +229,7 @@ class TestRoleViews(UffdTestCase): # TODO: verify that group memberships are updated (currently not possible with ldap mock!) def test_set_default(self): - db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service')) + db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service')) db.session.commit() role = Role(name='test') db.session.add(role) @@ -261,7 +261,7 @@ class TestRoleViews(UffdTestCase): admin_role = Role(name='admin', is_default=True) db.session.add(admin_role) admin_role.groups[self.get_admin_group()] = RoleGroup() - db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service')) + db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service')) db.session.commit() role = Role(name='test', is_default=True) db.session.add(role) diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py index 760b7a44..b31e579e 100644 --- a/tests/test_selfservice.py +++ b/tests/test_selfservice.py @@ -1,10 +1,11 @@ import datetime +import re import unittest from flask import url_for, request from uffd import create_app, db -from uffd.models import MailToken, PasswordToken, User, Role, RoleGroup +from uffd.models import PasswordToken, User, UserEmail, Role, RoleGroup from utils import dump, UffdTestCase @@ -18,13 +19,13 @@ class TestSelfservice(UffdTestCase): user = request.user self.assertIn(user.displayname.encode(), r.data) self.assertIn(user.loginname.encode(), r.data) - self.assertIn(user.mail.encode(), r.data) + self.assertIn(user.primary_email.address.encode(), r.data) def test_update_displayname(self): self.login_as('user') user = request.user r = self.client.post(path=url_for('selfservice.update_profile'), - data={'displayname': 'New Display Name', 'mail': user.mail}, + data={'displayname': 'New Display Name'}, follow_redirects=True) dump('update_displayname', r) self.assertEqual(r.status_code, 200) @@ -35,43 +36,209 @@ class TestSelfservice(UffdTestCase): self.login_as('user') user = request.user r = self.client.post(path=url_for('selfservice.update_profile'), - data={'displayname': '', 'mail': user.mail}, + data={'displayname': ''}, follow_redirects=True) dump('update_displayname_invalid', r) self.assertEqual(r.status_code, 200) _user = request.user self.assertNotEqual(_user.displayname, '') - def test_update_mail(self): + def test_add_email(self): self.login_as('user') - user = request.user - r = self.client.post(path=url_for('selfservice.update_profile'), - data={'displayname': user.displayname, 'mail': 'newemail@example.com'}, + r = self.client.post(path=url_for('selfservice.add_email'), + data={'address': 'new@example.com'}, follow_redirects=True) - dump('update_mail', r) + dump('selfservice_add_email', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertNotEqual(_user.mail, 'newemail@example.com') - token = MailToken.query.filter(MailToken.user == user).first() - self.assertEqual(token.newmail, 'newemail@example.com') - self.assertIn(token.token, str(self.app.last_mail.get_content())) - r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token=token.token), follow_redirects=True) + self.assertIn('new@example.com', self.app.last_mail['To']) + m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content())) + email_id, secret = m.groups() + email = UserEmail.query.get(email_id) + self.assertEqual(email.user, request.user) + self.assertEqual(email.address, 'new@example.com') + self.assertFalse(email.verified) + self.assertFalse(email.verification_expired) + self.assertTrue(email.verification_secret.verify(secret)) + + def test_add_email_duplicate(self): + self.login_as('user') + r = self.client.post(path=url_for('selfservice.add_email'), + data={'address': 'test@example.com'}, + follow_redirects=True) + dump('selfservice_add_email_duplicate', r) + self.assertFalse(hasattr(self.app, 'last_mail')) + self.assertEqual(len(self.get_user().all_emails), 1) + self.assertEqual(UserEmail.query.filter_by(user=None).all(), []) + + def test_verify_email(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com') + secret = email.start_verification() + db.session.add(email) + db.session.commit() + email_id = email.id + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email_id, secret=secret), follow_redirects=True) + dump('selfservice_verify_email', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertEqual(_user.mail, 'newemail@example.com') + email = UserEmail.query.get(email_id) + self.assertTrue(email.verified) + self.assertEqual(self.get_user().primary_email.address, 'test@example.com') - def test_update_mail_sendfailure(self): - self.app.config['MAIL_SKIP_SEND'] = 'fail' + def test_verify_email_notfound(self): self.login_as('user') - user = request.user - r = self.client.post(path=url_for('selfservice.update_profile'), - data={'displayname': user.displayname, 'mail': 'newemail@example.com'}, + r = self.client.get(path=url_for('selfservice.verify_email', email_id=2342, secret='invalidsecret'), follow_redirects=True) + dump('selfservice_verify_email_notfound', r) + + def test_verify_email_wrong_user(self): + self.login_as('user') + email = UserEmail(user=self.get_admin(), address='new@example.com') + secret = email.start_verification() + db.session.add(email) + db.session.commit() + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret=secret), follow_redirects=True) + dump('selfservice_verify_email_wrong_user', r) + self.assertFalse(email.verified) + + def test_verify_email_wrong_secret(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com') + secret = email.start_verification() + db.session.add(email) + db.session.commit() + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret='invalidsecret'), follow_redirects=True) + dump('selfservice_verify_email_wrong_secret', r) + self.assertFalse(email.verified) + + def test_verify_email_expired(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com') + secret = email.start_verification() + email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1) + db.session.add(email) + db.session.commit() + r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret='invalidsecret'), follow_redirects=True) + dump('selfservice_verify_email_expired', r) + self.assertFalse(email.verified) + + def test_verify_email_legacy(self): + self.login_as('user') + email = UserEmail( + user=self.get_user(), + address='new@example.com', + verification_legacy_id=1337, + _verification_secret='{PLAIN}ZgvsUs2bZjr9Whpy1la7Q0PHbhjmpXtNdH1mCmDbQP7', + verification_expires=datetime.datetime.utcnow()+datetime.timedelta(days=1) + ) + db.session.add(email) + db.session.commit() + email_id = email.id + r = self.client.get(path=f'/self/token/mail_verification/1337/ZgvsUs2bZjr9Whpy1la7Q0PHbhjmpXtNdH1mCmDbQP7', follow_redirects=True) + dump('selfservice_verify_email_legacy', r) + self.assertEqual(r.status_code, 200) + email = UserEmail.query.get(email_id) + self.assertTrue(email.verified) + self.assertEqual(self.get_user().primary_email, email) + + def test_retry_email_verification(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com') + old_secret = email.start_verification() + db.session.add(email) + db.session.commit() + r = self.client.get(path=url_for('selfservice.retry_email_verification', email_id=email.id), follow_redirects=True) + dump('selfservice_retry_email_verification', r) + self.assertEqual(r.status_code, 200) + self.assertIn('new@example.com', self.app.last_mail['To']) + m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content())) + email_id, secret = m.groups() + email = UserEmail.query.get(email_id) + self.assertEqual(email.user, request.user) + self.assertEqual(email.address, 'new@example.com') + self.assertFalse(email.verified) + self.assertFalse(email.verification_expired) + self.assertTrue(email.verification_secret.verify(secret)) + self.assertFalse(email.verification_secret.verify(old_secret)) + + def test_delete_email(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com', verified=True) + db.session.add(email) + self.get_user().recovery_email = email + db.session.commit() + r = self.client.post(path=url_for('selfservice.delete_email', email_id=email.id), follow_redirects=True) + dump('selfservice_delete_email', r) + self.assertEqual(r.status_code, 200) + self.assertIsNone(UserEmail.query.filter_by(address='new@example.com').first()) + self.assertIsNone(self.get_user().recovery_email) + self.assertEqual(self.get_user().primary_email.address, 'test@example.com') + + def test_delete_email_invalid(self): + self.login_as('user') + r = self.client.post(path=url_for('selfservice.delete_email', email_id=2324), follow_redirects=True) + self.assertEqual(r.status_code, 404) + + def test_delete_email_primary(self): + self.login_as('user') + r = self.client.post(path=url_for('selfservice.delete_email', email_id=request.user.primary_email.id), follow_redirects=True) + dump('selfservice_delete_email_primary', r) + self.assertEqual(self.get_user().primary_email.address, 'test@example.com') + + def test_update_email_preferences(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com', verified=True) + db.session.add(email) + db.session.commit() + email_id = email.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) - dump('update_mail_sendfailure', r) + dump('selfservice_update_email_preferences', r) self.assertEqual(r.status_code, 200) - _user = request.user - self.assertNotEqual(_user.mail, 'newemail@example.com') - # Maybe also check that there is no new token in the db + self.assertEqual(self.get_user().primary_email.id, email.id) + self.assertIsNone(self.get_user().recovery_email) + 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, 200) + self.assertEqual(self.get_user().primary_email.id, old_email_id) + self.assertEqual(self.get_user().recovery_email.id, email_id) + + def test_update_email_preferences_unverified(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com') + db.session.add(email) + db.session.commit() + email_id = email.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) + 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) + self.assertIsNone(self.get_user().recovery_email) + + def test_update_email_preferences_invalid(self): + self.login_as('user') + email = UserEmail(user=self.get_user(), address='new@example.com', verified=True) + db.session.add(email) + 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) def test_change_password(self): self.login_as('user') @@ -141,70 +308,14 @@ class TestSelfservice(UffdTestCase): self.assertEqual(len(_user.roles), 1) self.assertEqual(list(_user.roles)[0].name, 'testrole2') - def test_token_mail_emptydb(self): - self.login_as('user') - user = request.user - r = self.client.get(path=url_for('selfservice.token_mail', token_id=1, token='A'*128), follow_redirects=True) - dump('token_mail_emptydb', r) - self.assertEqual(r.status_code, 200) - _user = request.user - self.assertEqual(_user.mail, user.mail) - - def test_token_mail_invalid(self): - self.login_as('user') - user = request.user - old_mail = user.mail - token = MailToken(user=user, newmail='newusermail@example.com') - db.session.add(token) - db.session.commit() - r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token='A'*128), follow_redirects=True) - dump('token_mail_invalid', r) - self.assertEqual(r.status_code, 200) - _user = request.user - self.assertEqual(_user.mail, old_mail) - - def test_token_mail_wrong_user(self): - self.login_as('user') - user = request.user - old_mail = user.mail - admin_user = self.get_admin() - old_admin_mail = admin_user.mail - db.session.add(MailToken(user=user, newmail='newusermail@example.com')) - admin_token = MailToken(user=admin_user, newmail='newadminmail@example.com') - db.session.add(admin_token) - db.session.commit() - r = self.client.get(path=url_for('selfservice.token_mail', token_id=admin_token.id, token=admin_token.token), follow_redirects=True) - dump('token_mail_wrong_user', r) - self.assertEqual(r.status_code, 403) - _user = self.get_user() - _admin_user = self.get_admin() - self.assertEqual(_user.mail, old_mail) - self.assertEqual(_admin_user.mail, old_admin_mail) - - def test_token_mail_expired(self): - self.login_as('user') - user = request.user - old_mail = user.mail - token = MailToken(user=user, newmail='newusermail@example.com', - created=(datetime.datetime.utcnow() - datetime.timedelta(days=10))) - db.session.add(token) - db.session.commit() - r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token=token.token), follow_redirects=True) - dump('token_mail_expired', r) - self.assertEqual(r.status_code, 200) - _user = request.user - self.assertEqual(_user.mail, old_mail) - tokens = MailToken.query.filter(MailToken.user == _user).all() - self.assertEqual(len(tokens), 1) - self.assertTrue(tokens[0].expired) - def test_forgot_password(self): user = self.get_user() r = self.client.get(path=url_for('selfservice.forgot_password')) dump('forgot_password', r) self.assertEqual(r.status_code, 200) + user = self.get_user() r = self.client.post(path=url_for('selfservice.forgot_password'), - data={'loginname': user.loginname, 'mail': user.mail}, follow_redirects=True) + data={'loginname': user.loginname, 'mail': user.primary_email.address}, follow_redirects=True) dump('forgot_password_submit', r) self.assertEqual(r.status_code, 200) token = PasswordToken.query.filter(PasswordToken.user == user).first() @@ -215,8 +326,9 @@ class TestSelfservice(UffdTestCase): user = self.get_user() r = self.client.get(path=url_for('selfservice.forgot_password')) self.assertEqual(r.status_code, 200) + user = self.get_user() r = self.client.post(path=url_for('selfservice.forgot_password'), - data={'loginname': 'not_a_user', 'mail': user.mail}, follow_redirects=True) + data={'loginname': 'not_a_user', 'mail': user.primary_email.address}, follow_redirects=True) dump('forgot_password_submit_wrong_user', r) self.assertEqual(r.status_code, 200) self.assertFalse(hasattr(self.app, 'last_mail')) diff --git a/tests/test_services.py b/tests/test_services.py index 643b1b45..0c697023 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -19,14 +19,14 @@ class TestServiceUser(UffdTestCase): service_count = Service.query.count() user_count = User.query.count() self.assertEqual(ServiceUser.query.count(), service_count * user_count) - db.session.add(User(loginname='newuser1', displayname='New User', mail='new1@example.com')) + db.session.add(User(loginname='newuser1', displayname='New User', primary_email_address='new1@example.com')) db.session.commit() self.assertEqual(ServiceUser.query.count(), service_count * (user_count + 1)) db.session.add(Service(name='service3')) db.session.commit() self.assertEqual(ServiceUser.query.count(), (service_count + 1) * (user_count + 1)) - db.session.add(User(loginname='newuser2', displayname='New User', mail='new2@example.com')) - db.session.add(User(loginname='newuser3', displayname='New User', mail='new3@example.com')) + db.session.add(User(loginname='newuser2', displayname='New User', primary_email_address='new2@example.com')) + db.session.add(User(loginname='newuser3', displayname='New User', primary_email_address='new3@example.com')) db.session.add(Service(name='service4')) db.session.add(Service(name='service5')) db.session.commit() @@ -47,7 +47,7 @@ class TestServiceUser(UffdTestCase): 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.mail) + self.assertEqual(service_user.real_email, user.primary_email.address) def test_remailer_email(self): user = self.get_user() @@ -66,12 +66,12 @@ class TestServiceUser(UffdTestCase): remailer_email = remailer.build_address(service.id, user.id) # 1. remailer not setup self.app.config['REMAILER_DOMAIN'] = '' - self.assertIsNone(ServiceUser.get_by_remailer_email(user.mail)) + self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address)) self.assertIsNone(ServiceUser.get_by_remailer_email(remailer_email)) self.assertIsNone(ServiceUser.get_by_remailer_email('invalid')) # 2. remailer setup self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertIsNone(ServiceUser.get_by_remailer_email(user.mail)) + self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address)) self.assertEqual(ServiceUser.get_by_remailer_email(remailer_email), service_user) self.assertIsNone(ServiceUser.get_by_remailer_email('invalid')) @@ -83,17 +83,17 @@ class TestServiceUser(UffdTestCase): remailer_email = remailer.build_address(service.id, user.id) # 1. remailer not setup self.app.config['REMAILER_DOMAIN'] = '' - self.assertEqual(service_user.email, user.mail) + self.assertEqual(service_user.email, user.primary_email.address) # 2. remailer setup + service.use_remailer disabled self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertEqual(service_user.email, user.mail) + self.assertEqual(service_user.email, user.primary_email.address) # 3. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS unset service.use_remailer = True db.session.commit() self.assertEqual(service_user.email, remailer_email) # 4. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS does not include user self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin'] - self.assertEqual(service_user.email, user.mail) + self.assertEqual(service_user.email, user.primary_email.address) # 5. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS includes user self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser'] self.assertEqual(service_user.email, remailer_email) @@ -103,7 +103,7 @@ class TestServiceUser(UffdTestCase): return {(su.service_id, su.user_id) for su in ServiceUser.filter_query_by_email(ServiceUser.query, value)} user1 = self.get_user() - user2 = User(loginname='user2', mail=user1.mail, displayname='User 2') + user2 = User(loginname='user2', primary_email_address=user1.primary_email.address, displayname='User 2') db.session.add(user2) db.session.commit() service1 = Service.query.filter_by(name='service1').first() # use_remailer=False @@ -116,7 +116,7 @@ class TestServiceUser(UffdTestCase): # 1. remailer disabled self.app.config['REMAILER_DOMAIN'] = '' - self.assertEqual(run_query(user1.mail), { + self.assertEqual(run_query(user1.primary_email.address), { (service1.id, user1.id), (service1.id, user2.id), (service2.id, user1.id), (service2.id, user2.id), }) @@ -126,7 +126,7 @@ class TestServiceUser(UffdTestCase): # 2. remailer enabled + REMAILER_LIMIT_TO_USERS unset self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertEqual(run_query(user1.mail), { + self.assertEqual(run_query(user1.primary_email.address), { (service1.id, user1.id), (service1.id, user2.id), }) self.assertEqual(run_query(remailer_email1_1), set()) @@ -138,7 +138,7 @@ class TestServiceUser(UffdTestCase): # 3. remailer enabled + REMAILER_LIMIT_TO_USERS includes testuser self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser'] - self.assertEqual(run_query(user1.mail), { + self.assertEqual(run_query(user1.primary_email.address), { (service1.id, user1.id), (service1.id, user2.id), (service2.id, user2.id), }) @@ -153,7 +153,7 @@ class TestServiceUser(UffdTestCase): # 4. remailer enabled + REMAILER_LIMIT_TO_USERS does not include user (should behave the same as 1.) self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin'] - self.assertEqual(run_query(user1.mail), { + self.assertEqual(run_query(user1.primary_email.address), { (service1.id, user1.id), (service1.id, user2.id), (service2.id, user1.id), (service2.id, user2.id), }) diff --git a/tests/test_signup.py b/tests/test_signup.py index cfb599d5..9affe7f9 100644 --- a/tests/test_signup.py +++ b/tests/test_signup.py @@ -122,7 +122,7 @@ class TestSignupModel(UffdTestCase): user = User.query.filter_by(loginname='newuser').one_or_none() self.assertEqual(user.loginname, 'newuser') self.assertEqual(user.displayname, 'New User') - self.assertEqual(user.mail, 'test@example.com') + self.assertEqual(user.primary_email.address, 'test@example.com') def test_finish_completed(self): signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret') @@ -171,7 +171,7 @@ class TestSignupModel(UffdTestCase): signup = Signup.query.get(signup1_id) self.assert_finish_failure(signup, 'notsecret') user = User.query.filter_by(loginname='newuser').one_or_none() - self.assertEqual(user.mail, 'test2@example.com') + self.assertEqual(user.primary_email.address, 'test2@example.com') class TestSignupViews(UffdTestCase): def setUpApp(self): @@ -333,7 +333,7 @@ class TestSignupViews(UffdTestCase): self.assertTrue(signup.completed) self.assertEqual(signup.user.loginname, 'newuser') self.assertEqual(signup.user.displayname, 'New User') - self.assertEqual(signup.user.mail, 'test@example.com') + self.assertEqual(signup.user.primary_email.address, 'test@example.com') self.assertIsNotNone(login_get_user('newuser', 'notsecret')) def test_confirm_loggedin(self): diff --git a/tests/test_user.py b/tests/test_user.py index 190234d0..b023510c 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, Group, Role, RoleGroup, Service +from uffd.models import User, UserEmail, Group, Role, RoleGroup, Service from utils import dump, UffdTestCase @@ -42,9 +42,9 @@ class TestUserModel(UffdTestCase): self.app.config['USER_SERVICE_MAX_UID'] =19999 User.query.delete() db.session.commit() - user0 = User(loginname='user0', displayname='user0', mail='user0@example.com') - user1 = User(loginname='user1', displayname='user1', mail='user1@example.com') - user2 = User(loginname='user2', displayname='user2', mail='user2@example.com') + user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') + user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') + user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com') db.session.add_all([user0, user1, user2]) db.session.commit() self.assertEqual(user0.unix_uid, 10000) @@ -52,12 +52,12 @@ class TestUserModel(UffdTestCase): self.assertEqual(user2.unix_uid, 10002) db.session.delete(user1) db.session.commit() - user3 = User(loginname='user3', displayname='user3', mail='user3@example.com') + user3 = User(loginname='user3', displayname='user3', primary_email_address='user3@example.com') db.session.add(user3) db.session.commit() self.assertEqual(user3.unix_uid, 10003) - service0 = User(loginname='service0', displayname='service0', mail='service0@example.com', is_service_user=True) - service1 = User(loginname='service1', displayname='service1', mail='service1@example.com', is_service_user=True) + service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True) + service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True) db.session.add_all([service0, service1]) db.session.commit() self.assertEqual(service0.unix_uid, 19000) @@ -70,9 +70,9 @@ class TestUserModel(UffdTestCase): self.app.config['USER_SERVICE_MAX_UID'] = 19999 User.query.delete() db.session.commit() - user0 = User(loginname='user0', displayname='user0', mail='user0@example.com') - service0 = User(loginname='service0', displayname='service0', mail='service0@example.com', is_service_user=True) - user1 = User(loginname='user1', displayname='user1', mail='user1@example.com') + user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') + service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True) + user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') db.session.add_all([user0, service0, user1]) db.session.commit() self.assertEqual(user0.unix_uid, 10000) @@ -84,31 +84,74 @@ class TestUserModel(UffdTestCase): self.app.config['USER_MAX_UID'] = 10001 User.query.delete() db.session.commit() - user0 = User(loginname='user0', displayname='user0', mail='user0@example.com') - user1 = User(loginname='user1', displayname='user1', mail='user1@example.com') + user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') + user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') db.session.add_all([user0, user1]) db.session.commit() self.assertEqual(user0.unix_uid, 10000) self.assertEqual(user1.unix_uid, 10001) with self.assertRaises(sqlalchemy.exc.IntegrityError): - user2 = User(loginname='user2', displayname='user2', mail='user2@example.com') + user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com') db.session.add(user2) db.session.commit() - def test_set_mail(self): + def test_init_primary_email_address(self): + user = User(primary_email_address='foobar@example.com') + self.assertEqual(user.primary_email.address, 'foobar@example.com') + self.assertEqual(user.primary_email.verified, True) + self.assertEqual(user.primary_email.user, user) + user = User(primary_email_address='invalid') + self.assertEqual(user.primary_email.address, 'invalid') + self.assertEqual(user.primary_email.verified, True) + self.assertEqual(user.primary_email.user, user) + + def test_set_primary_email_address(self): user = User() - self.assertTrue(user.set_mail('foobar@example.com')) - self.assertEqual(user.mail, 'foobar@example.com') - self.assertFalse(user.set_mail('')) - self.assertEqual(user.mail, 'foobar@example.com') - self.assertFalse(user.set_mail('foobar')) - self.assertFalse(user.set_mail('@')) + self.assertFalse(user.set_primary_email_address('invalid')) + self.assertIsNone(user.primary_email) + self.assertEqual(len(user.all_emails), 0) + self.assertTrue(user.set_primary_email_address('foobar@example.com')) + self.assertEqual(user.primary_email.address, 'foobar@example.com') + self.assertEqual(len(user.all_emails), 1) + self.assertFalse(user.set_primary_email_address('invalid')) + self.assertEqual(user.primary_email.address, 'foobar@example.com') + self.assertEqual(len(user.all_emails), 1) + self.assertTrue(user.set_primary_email_address('other@example.com')) + self.assertEqual(user.primary_email.address, 'other@example.com') + self.assertEqual(len(user.all_emails), 2) + self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'}) + +class TestUserEmailModel(UffdTestCase): + def test_set_address(self): + email = UserEmail() + self.assertFalse(email.set_address('invalid')) + self.assertIsNone(email.address) + self.assertFalse(email.set_address('')) + self.assertFalse(email.set_address('@')) self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com' - self.assertFalse(user.set_mail('foobar@remailer.example.com')) - self.assertFalse(user.set_mail('v1-1-testuser@remailer.example.com')) - self.assertFalse(user.set_mail('v1-1-testuser @ remailer.example.com')) - self.assertFalse(user.set_mail('v1-1-testuser@REMAILER.example.com')) - self.assertFalse(user.set_mail('v1-1-testuser@foobar@remailer.example.com')) + self.assertFalse(email.set_address('foobar@remailer.example.com')) + self.assertFalse(email.set_address('v1-1-testuser@remailer.example.com')) + self.assertFalse(email.set_address('v1-1-testuser @ remailer.example.com')) + self.assertFalse(email.set_address('v1-1-testuser@REMAILER.example.com')) + self.assertFalse(email.set_address('v1-1-testuser@foobar@remailer.example.com')) + self.assertTrue(email.set_address('foobar@example.com')) + self.assertEqual(email.address, 'foobar@example.com') + + def test_verification(self): + email = UserEmail(address='foo@example.com') + self.assertFalse(email.finish_verification('test')) + secret = email.start_verification() + self.assertTrue(email.verification_secret) + self.assertTrue(email.verification_secret.verify(secret)) + self.assertFalse(email.verification_expired) + self.assertFalse(email.finish_verification('test')) + orig_expires = email.verification_expires + email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1) + self.assertFalse(email.finish_verification(secret)) + email.verification_expires = orig_expires + self.assertTrue(email.finish_verification(secret)) + self.assertFalse(email.verification_secret) + self.assertTrue(email.verification_expired) class TestUserViews(UffdTestCase): def setUp(self): @@ -133,7 +176,7 @@ class TestUserViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) r = self.client.post(path=url_for('user.update'), - data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User', f'role-{role1_id}': '1', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_submit', r) self.assertEqual(r.status_code, 200) @@ -143,7 +186,7 @@ class TestUserViews(UffdTestCase): self.assertFalse(user_.is_service_user) self.assertEqual(user_.loginname, 'newuser') self.assertEqual(user_.displayname, 'New User') - self.assertEqual(user_.mail, 'newuser@example.com') + self.assertEqual(user_.primary_email.address, 'newuser@example.com') self.assertGreaterEqual(user_.unix_uid, self.app.config['USER_MIN_UID']) self.assertLessEqual(user_.unix_uid, self.app.config['USER_MAX_UID']) role1 = Role(name='role1') @@ -163,7 +206,7 @@ class TestUserViews(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) r = self.client.post(path=url_for('user.update'), - data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User', f'role-{role1_id}': '1', 'password': 'newpassword', 'serviceaccount': '1'}, follow_redirects=True) dump('user_new_submit', r) self.assertEqual(r.status_code, 200) @@ -173,7 +216,7 @@ class TestUserViews(UffdTestCase): self.assertTrue(user.is_service_user) self.assertEqual(user.loginname, 'newuser') self.assertEqual(user.displayname, 'New User') - self.assertEqual(user.mail, 'newuser@example.com') + self.assertEqual(user.primary_email.address, 'newuser@example.com') self.assertTrue(user.unix_uid) role1 = Role(name='role1') self.assertEqual(roles, ['role1']) @@ -181,7 +224,7 @@ class TestUserViews(UffdTestCase): def test_new_invalid_loginname(self): r = self.client.post(path=url_for('user.update'), - data={'loginname': '!newuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': '!newuser', 'email': 'newuser@example.com', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_invalid_loginname', r) self.assertEqual(r.status_code, 200) @@ -189,7 +232,7 @@ class TestUserViews(UffdTestCase): def test_new_empty_loginname(self): r = self.client.post(path=url_for('user.update'), - data={'loginname': '', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': '', 'email': 'newuser@example.com', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_empty_loginname', r) self.assertEqual(r.status_code, 200) @@ -197,7 +240,7 @@ class TestUserViews(UffdTestCase): def test_new_empty_email(self): r = self.client.post(path=url_for('user.update'), - data={'loginname': 'newuser', 'mail': '', 'displayname': 'New User', + data={'loginname': 'newuser', 'email': '', 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_new_empty_email', r) self.assertEqual(r.status_code, 200) @@ -205,7 +248,7 @@ class TestUserViews(UffdTestCase): def test_new_invalid_display_name(self): r = self.client.post(path=url_for('user.update'), - data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'A'*200, + data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'A'*200, 'password': 'newpassword'}, follow_redirects=True) dump('user_new_invalid_display_name', r) self.assertEqual(r.status_code, 200) @@ -213,6 +256,7 @@ class TestUserViews(UffdTestCase): def test_update(self): user_unupdated = self.get_user() + email_id = str(user_unupdated.primary_email.id) db.session.add(Role(name='base', is_default=True)) role1 = Role(name='role1') db.session.add(role1) @@ -225,14 +269,16 @@ class TestUserViews(UffdTestCase): dump('user_update', r) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User', - f'role-{role1_id}': '1', 'password': ''}, follow_redirects=True) + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary', + 'displayname': 'New User', f'role-{role1_id}': '1', 'password': ''}, + follow_redirects=True) dump('user_update_submit', r) self.assertEqual(r.status_code, 200) user_updated = self.get_user() roles = sorted([r.name for r in user_updated.roles_effective]) self.assertEqual(user_updated.displayname, 'New User') - self.assertEqual(user_updated.mail, 'newuser@example.com') + self.assertEqual(user_updated.primary_email.address, 'test@example.com') self.assertEqual(user_updated.unix_uid, user_unupdated.unix_uid) self.assertEqual(user_updated.loginname, user_unupdated.loginname) self.assertTrue(user_updated.password.verify('userpassword')) @@ -240,16 +286,19 @@ class TestUserViews(UffdTestCase): def test_update_password(self): user_unupdated = self.get_user() + email_id = str(user_unupdated.primary_email.id) r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary', + 'displayname': 'New User', 'password': 'newpassword'}, follow_redirects=True) dump('user_update_password', r) self.assertEqual(r.status_code, 200) user_updated = self.get_user() self.assertEqual(user_updated.displayname, 'New User') - self.assertEqual(user_updated.mail, 'newuser@example.com') + self.assertEqual(user_updated.primary_email.address, 'test@example.com') self.assertEqual(user_updated.unix_uid, user_unupdated.unix_uid) self.assertEqual(user_updated.loginname, user_unupdated.loginname) self.assertTrue(user_updated.password.verify('newpassword')) @@ -257,10 +306,13 @@ class TestUserViews(UffdTestCase): def test_update_invalid_password(self): user_unupdated = self.get_user() + email_id = str(user_unupdated.primary_email.id) r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary', + 'displayname': 'New User', 'password': 'A'}, follow_redirects=True) dump('user_update_invalid_password', r) self.assertEqual(r.status_code, 200) @@ -268,16 +320,19 @@ class TestUserViews(UffdTestCase): self.assertFalse(user_updated.password.verify('A')) self.assertTrue(user_updated.password.verify('userpassword')) self.assertEqual(user_updated.displayname, user_unupdated.displayname) - self.assertEqual(user_updated.mail, user_unupdated.mail) + self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address) self.assertEqual(user_updated.loginname, user_unupdated.loginname) # Regression test for #100 (login not possible if password contains character disallowed by SASLprep) def test_update_saslprep_invalid_password(self): user_unupdated = self.get_user() + email_id = str(user_unupdated.primary_email.id) r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User', + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary', + 'displayname': 'New User', 'password': 'newpassword\n'}, follow_redirects=True) dump('user_update_invalid_password', r) self.assertEqual(r.status_code, 200) @@ -285,37 +340,55 @@ class TestUserViews(UffdTestCase): self.assertFalse(user_updated.password.verify('newpassword\n')) self.assertTrue(user_updated.password.verify('userpassword')) self.assertEqual(user_updated.displayname, user_unupdated.displayname) - self.assertEqual(user_updated.mail, user_unupdated.mail) + self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address) self.assertEqual(user_updated.loginname, user_unupdated.loginname) - def test_update_empty_email(self): - user_unupdated = self.get_user() - r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True) - self.assertEqual(r.status_code, 200) - r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': '', 'displayname': 'New User', - 'password': 'newpassword'}, follow_redirects=True) - dump('user_update_empty_mail', r) + def test_update_email(self): + user = self.get_user() + email = UserEmail(user=user, address='foo@example.com') + db.session.commit() + email1_id = user.primary_email.id + email2_id = email.id + r = self.client.post(path=url_for('user.update', id=user.id), + data={'loginname': 'testuser', + f'email-{email1_id}-present': '1', + f'email-{email2_id}-present': '1', + f'email-{email2_id}-verified': '1', + 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, + 'displayname': 'Test User', 'password': ''}, + follow_redirects=True) + dump('user_update_email', r) self.assertEqual(r.status_code, 200) - user_updated = self.get_user() - self.assertEqual(user_updated.displayname, user_unupdated.displayname) - self.assertEqual(user_updated.mail, user_unupdated.mail) - self.assertEqual(user_updated.loginname, user_unupdated.loginname) - self.assertFalse(user_updated.password.verify('newpassword')) - self.assertTrue(user_updated.password.verify('userpassword')) + user = self.get_user() + self.assertEqual(user.primary_email.id, email2_id) + self.assertEqual(user.recovery_email.id, email1_id) + self.assertEqual( + {email.address: email.verified for email in user.all_emails}, + { + 'test@example.com': True, + 'foo@example.com': True, + 'new1@example.com': False, + 'new2@example.com': True, + } + ) def test_update_invalid_display_name(self): user_unupdated = self.get_user() + email_id = str(user_unupdated.primary_email.id) r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('user.update', id=user_unupdated.id), - data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'A'*200, + data={'loginname': 'testuser', + f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary', + 'displayname': 'A'*200, 'password': 'newpassword'}, follow_redirects=True) dump('user_update_invalid_display_name', r) self.assertEqual(r.status_code, 200) user_updated = self.get_user() self.assertEqual(user_updated.displayname, user_unupdated.displayname) - self.assertEqual(user_updated.mail, user_unupdated.mail) + self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address) self.assertEqual(user_updated.loginname, user_unupdated.loginname) self.assertFalse(user_updated.password.verify('newpassword')) self.assertTrue(user_updated.password.verify('userpassword')) @@ -361,42 +434,42 @@ newuser12,newuser12@example.com,{role1.id};{role1.id} self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser1') self.assertEqual(user.displayname, 'newuser1') - self.assertEqual(user.mail, 'newuser1@example.com') + self.assertEqual(user.primary_email.address, 'newuser1@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, []) user = User.query.filter_by(loginname='newuser2').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser2') self.assertEqual(user.displayname, 'newuser2') - self.assertEqual(user.mail, 'newuser2@example.com') + self.assertEqual(user.primary_email.address, 'newuser2@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, ['role1']) user = User.query.filter_by(loginname='newuser3').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser3') self.assertEqual(user.displayname, 'newuser3') - self.assertEqual(user.mail, 'newuser3@example.com') + self.assertEqual(user.primary_email.address, 'newuser3@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, ['role1', 'role2']) user = User.query.filter_by(loginname='newuser4').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser4') self.assertEqual(user.displayname, 'newuser4') - self.assertEqual(user.mail, 'newuser4@example.com') + self.assertEqual(user.primary_email.address, 'newuser4@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, []) user = User.query.filter_by(loginname='newuser5').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser5') self.assertEqual(user.displayname, 'newuser5') - self.assertEqual(user.mail, 'newuser5@example.com') + self.assertEqual(user.primary_email.address, 'newuser5@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, []) user = User.query.filter_by(loginname='newuser6').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser6') self.assertEqual(user.displayname, 'newuser6') - self.assertEqual(user.mail, 'newuser6@example.com') + self.assertEqual(user.primary_email.address, 'newuser6@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, ['role1', 'role2']) self.assertIsNone(User.query.filter_by(loginname='newuser7').one_or_none()) @@ -406,14 +479,14 @@ newuser12,newuser12@example.com,{role1.id};{role1.id} self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser10') self.assertEqual(user.displayname, 'newuser10') - self.assertEqual(user.mail, 'newuser10@example.com') + self.assertEqual(user.primary_email.address, 'newuser10@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, []) user = User.query.filter_by(loginname='newuser11').one_or_none() self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser11') self.assertEqual(user.displayname, 'newuser11') - self.assertEqual(user.mail, 'newuser11@example.com') + self.assertEqual(user.primary_email.address, 'newuser11@example.com') # Currently the csv import is not very robust, imho newuser11 should have role1 and role2! roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, ['role2']) @@ -421,7 +494,7 @@ newuser12,newuser12@example.com,{role1.id};{role1.id} self.assertIsNotNone(user) self.assertEqual(user.loginname, 'newuser12') self.assertEqual(user.displayname, 'newuser12') - self.assertEqual(user.mail, 'newuser12@example.com') + self.assertEqual(user.primary_email.address, 'newuser12@example.com') roles = sorted([r.name for r in user.roles]) self.assertEqual(roles, ['role1']) @@ -464,7 +537,7 @@ class TestUserCLI(UffdTestCase): with self.app.test_request_context(): user = User.query.filter_by(loginname='newuser').first() self.assertIsNotNone(user) - self.assertEqual(user.mail, 'newmail@example.com') + self.assertEqual(user.primary_email.address, 'newmail@example.com') self.assertEqual(user.displayname, 'New Display Name') self.assertTrue(user.password.verify('newpassword')) self.assertEqual(user.roles, Role.query.filter_by(name='admin').all()) @@ -487,7 +560,7 @@ class TestUserCLI(UffdTestCase): with self.app.test_request_context(): user = User.query.filter_by(loginname='testuser').first() self.assertIsNotNone(user) - self.assertEqual(user.mail, 'newmail@example.com') + self.assertEqual(user.primary_email.address, 'newmail@example.com') self.assertEqual(user.displayname, 'New Display Name') self.assertTrue(user.password.verify('newpassword')) result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--add-role', 'admin', '--add-role', 'test']) diff --git a/tests/utils.py b/tests/utils.py index c4017b22..3b98e64b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -83,9 +83,9 @@ class UffdTestCase(unittest.TestCase): db.session.add(access_group) admin_group = Group(name='uffd_admin', unix_gid=20003, description='Admin access to uffd') db.session.add(admin_group) - testuser = User(loginname='testuser', unix_uid=10000, password='userpassword', mail='test@example.com', displayname='Test User', groups=[users_group, access_group]) + testuser = User(loginname='testuser', unix_uid=10000, password='userpassword', primary_email_address='test@example.com', displayname='Test User', groups=[users_group, access_group]) db.session.add(testuser) - testadmin = User(loginname='testadmin', unix_uid=10001, password='adminpassword', mail='admin@example.com', displayname='Test Admin', groups=[users_group, access_group, admin_group]) + testadmin = User(loginname='testadmin', unix_uid=10001, password='adminpassword', primary_email_address='admin@example.com', displayname='Test Admin', groups=[users_group, access_group, admin_group]) db.session.add(testadmin) testmail = Mail(uid='test', receivers=['test1@example.com', 'test2@example.com'], destinations=['testuser@mail.example.com']) db.session.add(testmail) diff --git a/uffd/commands/user.py b/uffd/commands/user.py index 5b82eec6..cf3dd6e0 100644 --- a/uffd/commands/user.py +++ b/uffd/commands/user.py @@ -15,7 +15,7 @@ def update_attrs(user, mail=None, displayname=None, password=None, add_role=tuple(), remove_role=tuple()): if password is None and prompt_password: password = click.prompt('Password', hide_input=True, confirmation_prompt='Confirm password') - if mail is not None and not user.set_mail(mail): + if mail is not None and not user.set_primary_email_address(mail): raise click.ClickException('Invalid mail address') if displayname is not None and not user.set_displayname(displayname): raise click.ClickException('Invalid displayname') @@ -50,7 +50,7 @@ def show(loginname): raise click.ClickException(f'User {loginname} not found') click.echo(f'Loginname: {user.loginname}') click.echo(f'Displayname: {user.displayname}') - click.echo(f'Mail: {user.mail}') + click.echo(f'Mail: {user.primary_email.address}') click.echo(f'Service User: {user.is_service_user}') click.echo(f'Roles: {", ".join([role.name for role in user.roles])}') click.echo(f'Groups: {", ".join([group.name for group in user.groups])}') @@ -70,6 +70,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add if displayname is None: displayname = loginname user = User(is_service_user=service) + user = User(is_service_user=service) if not user.set_loginname(loginname, ignore_blocklist=True): raise click.ClickException('Invalid loginname') try: diff --git a/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py b/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py index f5290617..125458d8 100644 --- a/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py +++ b/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py @@ -31,7 +31,7 @@ def iter_rows_paged(table, pk='id', limit=1000): if last_pk is not None: expr = expr.where(pk_column > last_pk) result = conn.execute(expr) - pk_index = result.keys().index(pk) + pk_index = list(result.keys()).index(pk) rows = result.fetchall() if not rows: break diff --git a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py new file mode 100644 index 00000000..ca83799f --- /dev/null +++ b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py @@ -0,0 +1,141 @@ +"""Multiple email addresses + +Revision ID: b273d7fdaa25 +Revises: 9f824f61d8ac +Create Date: 2022-08-19 22:52:48.730877 + +""" +from alembic import op +import sqlalchemy as sa +import datetime + +# revision identifiers, used by Alembic. +revision = 'b273d7fdaa25' +down_revision = 'b8fbefca3675' +branch_labels = None +depends_on = None + +def iter_rows_paged(table, pk='id', limit=1000): + conn = op.get_bind() + pk_column = getattr(table.c, pk) + last_pk = None + while True: + expr = table.select().order_by(pk_column).limit(limit) + if last_pk is not None: + expr = expr.where(pk_column > last_pk) + result = conn.execute(expr) + pk_index = list(result.keys()).index(pk) + rows = result.fetchall() + if not rows: + break + yield from rows + last_pk = rows[-1][pk_index] + +def upgrade(): + user_email_table = op.create_table('user_email', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('address', sa.String(length=128), nullable=False), + sa.Column('verified', sa.Boolean(), nullable=False), + sa.Column('verification_legacy_id', sa.Integer(), nullable=True), + sa.Column('verification_secret', sa.Text(), nullable=True), + sa.Column('verification_expires', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_user_email_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user_email')), + sa.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address') + ) + user_table = sa.table('user', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('mail', sa.VARCHAR(length=128), nullable=False), + ) + op.execute(user_email_table.insert().from_select( + ['user_id', 'address', 'verified'], + sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean())]) + )) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('primary_email_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('recovery_email_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(batch_op.f('fk_user_primary_email_id_user_email'), 'user_email', ['primary_email_id'], ['id'], onupdate='CASCADE') + batch_op.create_foreign_key(batch_op.f('fk_user_recovery_email_id_user_email'), 'user_email', ['recovery_email_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL') + meta = sa.MetaData(bind=op.get_bind()) + user_table = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('mail', sa.VARCHAR(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=True), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + op.execute(user_table.update().values(primary_email_id=sa.select([user_email_table.c.id]).where(user_email_table.c.user_id==user_table.c.id).limit(1).as_scalar())) + with op.batch_alter_table('user', copy_from=user_table) as batch_op: + batch_op.alter_column('primary_email_id', existing_type=sa.Integer(), nullable=False) + batch_op.drop_column('mail') + mailToken_table = sa.table('mailToken', + sa.column('id', sa.Integer()), + sa.column('token', sa.Text()), + sa.column('created', sa.DateTime()), + sa.column('newmail', sa.Text()), + sa.column('user_id', sa.Integer()), + ) + for token_id, token, created, newmail, user_id in iter_rows_paged(mailToken_table): + op.execute(user_email_table.insert().insert().values( + user_id=user_id, + address=newmail, + verified=False, + verification_legacy_id=token_id, + verification_secret='{PLAIN}'+token, + # in-python because of this + verification_expires=(created + datetime.timedelta(days=2)), + )) + op.drop_table('mailToken') + +def downgrade(): + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('mail', sa.VARCHAR(length=128), nullable=True)) + meta = sa.MetaData(bind=op.get_bind()) + user_table = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('mail', sa.VARCHAR(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=False), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + user_email_table = sa.table('user_email', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('address', sa.String(length=128), nullable=False), + ) + op.execute(user_table.update().values(mail=sa.select([user_email_table.c.address]).where(user_email_table.c.id==user_table.c.primary_email_id).limit(1).as_scalar())) + with op.batch_alter_table('user', copy_from=user_table) as batch_op: + batch_op.alter_column('mail', existing_type=sa.VARCHAR(length=128), nullable=False) + batch_op.drop_constraint(batch_op.f('fk_user_recovery_email_id_user_email'), type_='foreignkey') + batch_op.drop_constraint(batch_op.f('fk_user_primary_email_id_user_email'), type_='foreignkey') + batch_op.drop_column('recovery_email_id') + batch_op.drop_column('primary_email_id') + op.create_table('mailToken', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('token', sa.VARCHAR(length=128), nullable=False), + sa.Column('created', sa.DATETIME(), nullable=True), + sa.Column('newmail', sa.VARCHAR(length=255), nullable=True), + sa.Column('user_id', sa.INTEGER(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('user_email') + diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index cedc85e6..52d9709b 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -4,11 +4,11 @@ from .mail import Mail, MailReceiveAddress, MailDestinationAddress from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation from .role import Role, RoleGroup, RoleGroupMap -from .selfservice import PasswordToken, MailToken +from .selfservice import PasswordToken from .service import Service, ServiceUser, get_services from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation from .signup import Signup -from .user import User, Group +from .user import User, UserEmail, Group from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay __all__ = [ @@ -18,10 +18,11 @@ __all__ = [ 'MFAType', 'MFAMethod', 'RecoveryCodeMethod', 'TOTPMethod', 'WebauthnMethod', 'OAuth2Client', 'OAuth2RedirectURI', 'OAuth2LogoutURI', 'OAuth2Grant', 'OAuth2Token', 'OAuth2DeviceLoginInitiation', 'Role', 'RoleGroup', 'RoleGroupMap', - 'PasswordToken', 'MailToken', + 'PasswordToken', + 'Service', 'get_services', 'Service', 'ServiceUser', 'get_services', 'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation', 'Signup', - 'User', 'Group', + 'User', 'UserEmail', 'Group', 'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay', ] diff --git a/uffd/models/selfservice.py b/uffd/models/selfservice.py index 5c4d5eb1..be3f36a7 100644 --- a/uffd/models/selfservice.py +++ b/uffd/models/selfservice.py @@ -22,19 +22,3 @@ class PasswordToken(db.Model): if self.created is None: return False return self.created < datetime.datetime.utcnow() - datetime.timedelta(days=2) - -@cleanup_task.delete_by_attribute('expired') -class MailToken(db.Model): - __tablename__ = 'mailToken' - id = Column(Integer(), primary_key=True, autoincrement=True) - token = Column(String(128), default=token_urlfriendly, nullable=False) - created = Column(DateTime, default=datetime.datetime.utcnow) - user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) - user = relationship('User') - newmail = Column(String(255)) - - @hybrid_property - def expired(self): - if self.created is None: - return False - return self.created < datetime.datetime.utcnow() - datetime.timedelta(days=2) diff --git a/uffd/models/service.py b/uffd/models/service.py index 6953ee71..131400f0 100644 --- a/uffd/models/service.py +++ b/uffd/models/service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import relationship from uffd.database import db from uffd.remailer import remailer from uffd.tasks import cleanup_task -from .user import User +from .user import User, UserEmail class Service(db.Model): __tablename__ = 'service' @@ -53,7 +53,7 @@ class ServiceUser(db.Model): # Actual e-mail address that mails from the service are sent to @property def real_email(self): - return self.user.mail + return self.user.primary_email.address @property def remailer_email(self): @@ -91,9 +91,11 @@ class ServiceUser(db.Model): return query.filter(cls.user_id == service_user.user_id, cls.service_id == service_user.service_id) AliasedUser = db.aliased(User) + AliasedPrimaryEmail = 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.join(cls.service.of_type(AliasedService)) remailer_enabled_expr = AliasedService.use_remailer if remailer.configured else False @@ -102,7 +104,7 @@ 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), AliasedUser.mail == email)) + return query.filter(db.and_(db.not_(remailer_enabled_expr), AliasedPrimaryEmail.address == email)) @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/models/signup.py b/uffd/models/signup.py index c04dbb41..e56f565e 100644 --- a/uffd/models/signup.py +++ b/uffd/models/signup.py @@ -79,8 +79,8 @@ class Signup(db.Model): return False, _('Login name is invalid') if not User().set_displayname(self.displayname): return False, _('Display name is invalid') - if not User().set_mail(self.mail): - return False, _('Mail address is invalid') + if not User().set_primary_email_address(self.mail): + return False, _('E-Mail address is invalid') if not self.password: return False, _('Invalid password') if User.query.filter_by(loginname=self.loginname).all(): @@ -104,7 +104,7 @@ class Signup(db.Model): return None, _('Wrong password') if User.query.filter_by(loginname=self.loginname).all(): return None, _('A user with this login name already exists') - user = User(loginname=self.loginname, displayname=self.displayname, mail=self.mail, password=self.password) + user = User(loginname=self.loginname, displayname=self.displayname, primary_email_address=self.mail, password=self.password) db.session.add(user) user.update_groups() # pylint: disable=no-member self.user = user diff --git a/uffd/models/user.py b/uffd/models/user.py index f822e232..374afef6 100644 --- a/uffd/models/user.py +++ b/uffd/models/user.py @@ -1,14 +1,17 @@ import string import re +import datetime from flask import current_app, escape from flask_babel import lazy_gettext -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text -from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text, DateTime +from sqlalchemy.orm import relationship, validates +from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db -from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash from uffd.remailer import remailer +from uffd.utils import token_urlfriendly +from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash, HighEntropyPasswordHash # pylint: disable=E1101 user_groups = db.Table('user_groups', @@ -37,7 +40,39 @@ class User(db.Model): unix_uid = Column(Integer(), unique=True, nullable=False) loginname = Column(String(32), unique=True, nullable=False) displayname = Column(String(128), nullable=False) - mail = Column(String(128), nullable=False) + + all_emails = relationship( + 'UserEmail', + foreign_keys='UserEmail.user_id', + cascade='all, delete-orphan', + back_populates='user', + post_update=True, + ) + verified_emails = relationship( + 'UserEmail', + foreign_keys='UserEmail.user_id', + viewonly=True, + primaryjoin='and_(User.id == UserEmail.user_id, UserEmail.verified)', + ) + + primary_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE'), nullable=False) + primary_email = relationship('UserEmail', foreign_keys='User.primary_email_id') + + # recovery_email_id == NULL -> use primary email + recovery_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE', ondelete='SET NULL')) + recovery_email = relationship('UserEmail', foreign_keys='User.recovery_email_id') + + @validates('primary_email', 'recovery_email') + def validate_email(self, key, value): + if value is not None: + if not value.user: + value.user = self + if value.user != self: + raise ValueError(f'UserEmail assigned to User.{key} is not associated with user') + if not value.verified: + raise ValueError(f'UserEmail assigned to User.{key} is not verified') + return value + _password = Column('pwhash', Text(), nullable=True) password = PasswordHashAttribute('_password', LowEntropyPasswordHash) is_service_user = Column(Boolean(), default=False, nullable=False) @@ -46,6 +81,11 @@ class User(db.Model): service_users = relationship('ServiceUser', viewonly=True) + def __init__(self, primary_email_address=None, **kwargs): + super().__init__(**kwargs) + if primary_email_address is not None: + self.primary_email = UserEmail(address=primary_email_address, verified=True) + @property def unix_gid(self): return current_app.config['USER_GID'] @@ -97,18 +137,100 @@ class User(db.Model): self.password = value return True - def set_mail(self, value): + def set_primary_email_address(self, address): + # UserEmail.query.filter_by(user=self, address=address).first() would cause + # a flush, so we do this in python. A flush would cause an IntegrityError if + # this method is used a new User object, since primary_email_id is not + # nullable. + email = ([item for item in self.all_emails if item.address == address] or [None])[0] + if not email: + email = UserEmail() + if not email.set_address(address): + return False + email.verified = True + self.primary_email = email + return True + + # Somehow pylint non-deterministically fails to detect that .update_groups is set in role.models + def update_groups(self): + pass + +class UserEmail(db.Model): + __tablename__ = 'user_email' + id = Column(Integer(), primary_key=True, autoincrement=True) + + # We have a cyclic dependency between User.primary_email and UserEmail.user. + # To solve this, we make UserEmail.user nullable, add validators, and set + # post_update=True here and for the backref. + user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE', use_alter=True)) + user = relationship('User', foreign_keys='UserEmail.user_id', back_populates='all_emails', post_update=True) + + @validates('user') + def validate_user(self, key, value): # pylint: disable=unused-argument + if self.user is not None and self.user != value: + raise ValueError('UserEmail.user cannot be changed once set') + return value + + address = Column(String(128), nullable=False) + + @validates('address') + def validate_address(self, key, value): # pylint: disable=unused-argument + if self.address is not None and self.address != value: + raise ValueError('UserEmail.address cannot be changed once set') + return value + + verified = Column(Boolean(), default=False, nullable=False) + + @validates('verified') + def validate_verified(self, key, value): # pylint: disable=unused-argument + if self.verified and not value: + raise ValueError('UserEmail cannot be unverified once verified') + return value + + verification_legacy_id = Column(Integer()) # id of old MailToken + _verification_secret = Column('verification_secret', Text()) + verification_secret = PasswordHashAttribute('_verification_secret', HighEntropyPasswordHash) + verification_expires = Column(DateTime) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address'), + ) + + def set_address(self, value): if len(value) < 3 or '@' not in value: return False domain = value.rsplit('@', 1)[-1] if remailer.is_remailer_domain(domain): return False - self.mail = value + self.address = value return True - # Somehow pylint non-deterministically fails to detect that .update_groups is set in invite.modes - def update_groups(self): - pass + def start_verification(self): + if self.verified: + raise Exception('UserEmail.start_verification must not be called if address is already verified') + self.verification_legacy_id = None + secret = token_urlfriendly() + self.verification_secret = secret + self.verification_expires = datetime.datetime.utcnow() + datetime.timedelta(days=2) + return secret + + @hybrid_property + def verification_expired(self): + if self.verification_expires is None: + return True + return self.verification_expires < datetime.datetime.utcnow() + + def finish_verification(self, secret): + # pylint: disable=using-constant-test + if self.verification_expired: + return False + if not self.verification_secret.verify(secret): + return False + self.verification_legacy_id = None + self.verification_secret = None + self.verification_expires = None + self.verified = True + return True def next_id_expr(column, min_value, max_value): # db.func.max(column) + 1: highest used value in range + 1, NULL if no values in range diff --git a/uffd/templates/selfservice/mailverification.mail.txt b/uffd/templates/selfservice/mailverification.mail.txt index f508be90..4b705f28 100644 --- a/uffd/templates/selfservice/mailverification.mail.txt +++ b/uffd/templates/selfservice/mailverification.mail.txt @@ -1,6 +1,6 @@ Hi {{ user.displayname }}, you have requested to change your mail address. To confirm the change, please visit the following url: -{{ url_for('selfservice.token_mail', token_id=token.id, token=token.token, _external=True) }} +{{ url_for('selfservice.verify_email', email_id=email.id, secret=secret, _external=True) }} **The link is valid for 48h** diff --git a/uffd/templates/selfservice/self.html b/uffd/templates/selfservice/self.html index f32b2e19..39a36499 100644 --- a/uffd/templates/selfservice/self.html +++ b/uffd/templates/selfservice/self.html @@ -25,14 +25,81 @@ <label>{{_("Display Name")}}</label> <input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ user.displayname }}"> </div> + <button type="submit" class="btn btn-primary btn-block">{{_("Update Profile")}}</button> + </form> + </div> +</div> + +<hr> + +<div class="row"> + <div class="col-12 col-md-5"> + <h5>{{_("E-Mail Addresses")}}</h5> + <p>{{_("Add and delete addresses associated with your account. You will need to verify new addresses by opening a link set to them.")}}</p> + </div> + <div class="col-12 col-md-7"> + <form method="POST" action="{{ url_for('selfservice.add_email') }}" class="form mb-2"> + <div class="row m-0"> + <label class="sr-only" for="new-email-address">{{_("Email")}}</label> + <input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required> + <button type="submit" class="btn btn-primary mb-2 col">{{_("Add address")}}</button> + </div> + </form> + <table class="table mb-0"> + <tbody> + {% for email in user.all_emails|sort(attribute='id') %} + <tr> + <td class="pl-0"> + {{ email.address }} + {% if email == user.primary_email %} + <span class="badge badge-primary">{{ _('primary') }}</span> + {% elif not email.verified %} + <span class="badge badge-danger">{{ _('unverified') }}</span> + {% endif %} + </td> + <td class="pt-2 pb-1 pr-0"> + <form method="POST" action="{{ url_for('selfservice.delete_email', email_id=email.id) }}" onsubmit='return confirm({{_("Are you sure?")|tojson|e}});'> + <button type="submit" class="btn btn-sm btn-danger float-right ml-1 mb-1"{% if email == user.primary_email %} disabled title="{{ _('Cannot delete primary e-mail address') }}"{% endif %}>{{_("Delete")}}</button> + </form> + {% if not email.verified %} + <a href="{{ url_for('selfservice.retry_email_verification', email_id=email.id) }}" class="btn btn-sm btn-primary float-right mb-1">{{_("Retry verification")}}</a> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +</div> + +<hr> + +<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> + </div> + <div class="col-12 col-md-7"> + <form class="form" action="{{ url_for("selfservice.update_email_preferences") }}" method="POST"> <div class="form-group"> - <label>{{_("E-Mail Address")}}</label> - <input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail }}" required> - <small class="form-text text-muted"> - {{_("We will send you a confirmation mail to this address if you change it")}} - </small> + <label>{{_("Primary Address")}}</label> + <select name="primary_email" class="form-control"> + {% for email in user.all_emails if email.verified %} + <option value="{{ email.id }}" {{ 'selected' if email == user.primary_email }}>{{ email.address }}</option> + {% endfor %} + </select> </div> - <button type="submit" class="btn btn-primary btn-block">{{_("Update Profile")}}</button> + <div class="form-group"> + <label>{{_("Recovery Address")}}</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> + <button type="submit" class="btn btn-primary btn-block">{{_("Update E-Mail Preferences")}}</button> </form> </div> </div> @@ -91,6 +158,7 @@ </div> <div class="col-12 col-md-7"> <p>{{_("Administrators and role moderators can invite you to new roles.")}}</p> + <table class="table"> <thead> <tr> @@ -122,6 +190,7 @@ {% endif %} </tbody> </table> + </div> </div> diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html index 20f4f7a0..8b814f5d 100644 --- a/uffd/templates/user/show.html +++ b/uffd/templates/user/show.html @@ -1,5 +1,20 @@ {% extends 'base.html' %} +{% macro new_email_row(tmp_id) %} +<tr> + <td> + <input class="form-control form-control-sm" type="email" name="newemail-{{ tmp_id }}-address" placeholder="{{ _('New address') }}"> + </td> + <td class="text-center"> + <input type="checkbox" value="1" name="newemail-{{ tmp_id }}-verified"> + </td> + <td class="text-center"> + <button type="button" class="btn btn-sm delete-new-email-row d-none"><i class="fas fa-times"></i></button> + </td> +</tr> + +{% endmacro %} + {% block body %} <form action="{{ url_for("user.update", id=user.id) }}" method="POST"> <div class="align-self-center"> @@ -65,19 +80,76 @@ {{_("If you leave this empty it will be set to the login name. At most 128, at least 2 characters. No character restrictions.")}} </small> </div> + + {% if not user.id %} <div class="form-group col"> - <label for="user-mail">{{_("Mail")}}</label> - <input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail or '' }}"> + <label for="user-email">{{_("E-Mail Address")}}</label> + <input type="email" class="form-control" id="user-email" name="email" value=""> <small class="form-text text-muted"> - {{_("Check that the address is unique. A user can take over another account if both have the same mail address set.")}} + {{_("Make sure the address is correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}} </small> </div> + {% else %} + <div class="form-group col"> + <span>{{_("E-Mail Addresses")}}</span> + <table class="table table-sm mt-2"> + <thead> + <tr> + <th scope="col">{{_("Address")}}</th> + <th scope="col" class="text-center">{{_("Verified")}}</th> + <th scope="col" class="text-center">{{_("Delete")}}</th> + </tr> + </thead> + <tbody id="email-rows"> + {% for email in user.all_emails %} + <tr> + <td> + <input type="hidden" name="email-{{ email.id }}-present" value="1"> + {{ email.address }} + {% if email == user.primary_email %} + <span class="badge badge-primary">{{ _('primary') }}</span> + {% endif %} + </td> + <td class="text-center"> + <input type="checkbox" value="1" name="email-{{ email.id }}-verified"{{ ' checked disabled' if email.verified }}> + </td> + <td class="text-center"> + <input type="checkbox" value="1" name="email-{{ email.id }}-delete"{{ ' disabled' if email == user.primary_email }}> + </td> + </tr> + {% endfor %} + {{ new_email_row(0) }} + </tbody> + </table> + <small class="form-text text-muted"> + {{_("Make sure that addresses you add are correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}} + </small> + </div> + <div class="form-group col"> + <label>{{_("Primary E-Mail Address")}}</label> + <select name="primary_email" class="form-control"> + {% for email in user.all_emails if email.verified %} + <option value="{{ email.id }}"{{ ' selected' if email == user.primary_email }}>{{ email.address }}</option> + {% endfor %} + </select> + </div> + <div class="form-group col"> + <label>{{_("Recovery E-Mail Address")}}</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> + </div> + {% endif %} + <div class="form-group col"> <label for="user-loginname">{{_("Password")}}</label> {% if user.id %} <input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}"> {% else %} - <input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("mail to set it will be sent")}}" readonly> + <input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("E-Mail to set it will be sent")}}" readonly> {% endif %} <small class="form-text text-muted"> {{ User.PASSWORD_DESCRIPTION|safe }} @@ -159,4 +231,17 @@ </div> </div> </form> + +<script> +$('#email-rows').on('click', '.delete-new-email-row', function () { + $(this).closest('tr').remove() +}); +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)); + new_email_id ++; +}); +</script> + {% endblock %} diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 2675ce774b0fd969fc54ba5df443f0e0a845295a..d40e990ed5133f9eed8b87e4832fabb4c5e23389 100644 GIT binary patch delta 8789 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 delta 6766 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 diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 87a74d14..293df9db 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-14 13:15+0200\n" +"POT-Creation-Date: 2022-08-28 17:21+0200\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -18,333 +18,386 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.8.0\n" -#: uffd/ratelimit.py:76 +#: uffd/models/invite.py:82 uffd/models/invite.py:105 uffd/models/invite.py:110 +msgid "Invite link is invalid" +msgstr "Einladungslink ist ungültig" + +#: uffd/models/invite.py:84 +msgid "Invite link does not grant any roles" +msgstr "Einladungslink weist keine Rollen zu" + +#: uffd/models/invite.py:86 +msgid "Invite link does not grant any new roles" +msgstr "Einladungslink weist keine neuen Rollen zu" + +#: uffd/models/invite.py:91 uffd/models/signup.py:115 +#: uffd/templates/mfa/setup.html:225 +msgid "Success" +msgstr "Erfolgreich" + +#: uffd/models/ratelimit.py:76 msgid "a few seconds" msgstr "ein paar Sekunden" -#: uffd/ratelimit.py:78 +#: uffd/models/ratelimit.py:78 msgid "30 seconds" msgstr "30 Sekunden" -#: uffd/ratelimit.py:80 +#: uffd/models/ratelimit.py:80 msgid "one minute" msgstr "eine Minute" -#: uffd/ratelimit.py:82 +#: uffd/models/ratelimit.py:82 #, python-format msgid "%(minutes)d minutes" msgstr "%(minutes)d Minuten" -#: uffd/ratelimit.py:84 +#: uffd/models/ratelimit.py:84 msgid "one hour" msgstr "eine Stunde" -#: uffd/ratelimit.py:85 +#: uffd/models/ratelimit.py:85 #, python-format msgid "%(hours)d hours" msgstr "%(hours)d Stunden" -#: uffd/invite/models.py:81 uffd/invite/models.py:104 uffd/invite/models.py:109 -msgid "Invite link is invalid" -msgstr "Einladungslink ist ungültig" +#: uffd/models/signup.py:77 uffd/models/signup.py:102 +msgid "Invalid signup request" +msgstr "Ungültiger Account-Registrierungs-Link" -#: uffd/invite/models.py:83 -msgid "Invite link does not grant any roles" -msgstr "Einladungslink weist keine Rollen zu" +#: uffd/models/signup.py:79 +msgid "Login name is invalid" +msgstr "Anmeldename ist ungültig" -#: uffd/invite/models.py:85 -msgid "Invite link does not grant any new roles" -msgstr "Einladungslink weist keine neuen Rollen zu" +#: uffd/models/signup.py:81 +msgid "Display name is invalid" +msgstr "Anzeigename ist ungültig" -#: uffd/invite/models.py:90 uffd/mfa/templates/mfa/setup.html:225 -#: uffd/signup/models.py:115 -msgid "Success" -msgstr "Erfolgreich" +#: uffd/models/signup.py:83 uffd/views/selfservice.py:112 uffd/views/user.py:54 +#: uffd/views/user.py:73 +msgid "E-Mail address is invalid" +msgstr "Ungültige E-Mail-Adresse" -#: uffd/invite/views.py:46 -msgid "Invites" -msgstr "Einladungslinks" +#: uffd/models/signup.py:85 uffd/views/selfservice.py:49 +msgid "Invalid password" +msgstr "Passwort ungültig" -#: uffd/invite/views.py:75 -msgid "The \"Expires After\" date is too far in the future" -msgstr "Das Ablaufdatum liegt zu weit in der Zukunft" +#: uffd/models/signup.py:87 uffd/models/signup.py:106 +msgid "A user with this login name already exists" +msgstr "Ein Account mit diesem Anmeldenamen existiert bereits" -#: uffd/invite/views.py:78 -msgid "You are not allowed to create invite links with these permissions" -msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen" +#: uffd/models/signup.py:88 +msgid "Valid" +msgstr "Gültig" -#: uffd/invite/views.py:81 -msgid "Invite link must either allow signup or grant at least one role" +#: uffd/models/signup.py:104 uffd/views/signup.py:104 +msgid "Wrong password" +msgstr "Falsches Passwort" + +#: uffd/models/user.py:35 +#, python-format +msgid "" +"At least %(minlen)d and at most %(maxlen)d characters. Only letters, " +"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. " +"Please use a password manager." msgstr "" -"Einladungslink muss entweder Account-Registrierung erlauben oder Rollen " -"vergeben" +"%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen " +"und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte " +"verwende einen Passwort-Manager." -#: uffd/invite/views.py:111 uffd/invite/views.py:146 -msgid "Invalid invite link" -msgstr "Ungültiger Einladungslink" +#: uffd/templates/403.html:10 +msgid "Access Denied" +msgstr "Zugriff verweigert" -#: uffd/invite/views.py:129 -msgid "Roles successfully updated" -msgstr "Rollen erfolgreich geändert" +#: uffd/templates/403.html:17 +msgid "You don't have the permission to access this page." +msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen." -#: uffd/invite/views.py:149 -msgid "Invite link does not allow signup" -msgstr "Einladungslink erlaubt keine Account-Registrierung" +#: uffd/templates/base.html:84 +msgid "Change" +msgstr "Ändern" -#: uffd/invite/views.py:175 uffd/selfservice/views.py:49 -#: uffd/signup/views.py:49 -msgid "Passwords do not match" -msgstr "Die Passwörter stimmen nicht überein" +#: uffd/templates/base.html:92 uffd/templates/session/deviceauth.html:12 +msgid "Authorize Device Login" +msgstr "Gerätelogin erlauben" -#: uffd/invite/views.py:181 uffd/signup/views.py:55 -#, python-format -msgid "Too many signup requests with this mail address! Please wait %(delay)s." -msgstr "" -"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte " -"%(delay)s." +#: uffd/templates/base.html:93 uffd/templates/session/devicelogin.html:6 +msgid "Device Login" +msgstr "Gerätelogin" -#: uffd/invite/views.py:184 uffd/signup/views.py:58 -#, python-format -msgid "Too many requests! Please wait %(delay)s." -msgstr "Zu viele Anfragen! Bitte warte %(delay)s." +#: uffd/templates/base.html:99 uffd/templates/oauth2/logout.html:5 +msgid "Logout" +msgstr "Abmelden" -#: uffd/invite/views.py:199 uffd/signup/views.py:76 -msgid "Could not send mail" -msgstr "Mailversand fehlgeschlagen" +#: uffd/templates/base.html:106 uffd/templates/service/overview.html:15 +#: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20 +msgid "Login" +msgstr "Anmelden" + +#: uffd/templates/base.html:142 +msgid "About uffd" +msgstr "Über uffd" -#: uffd/invite/templates/invite/list.html:6 -#: uffd/mail/templates/mail/list.html:8 uffd/role/templates/role/list.html:8 -#: uffd/service/templates/service/index.html:8 -#: uffd/service/templates/service/show.html:50 -#: uffd/service/templates/service/show.html:78 -#: uffd/user/templates/group/list.html:8 uffd/user/templates/user/list.html:8 +#: 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 msgid "New" msgstr "Neu" -#: uffd/invite/templates/invite/list.html:12 +#: uffd/templates/group/list.html:14 +msgid "GID" +msgstr "GID" + +#: uffd/templates/group/list.html:15 uffd/templates/group/show.html:26 +#: uffd/templates/invite/new.html:35 uffd/templates/mail/list.html:14 +#: uffd/templates/mail/show.html:7 uffd/templates/mfa/setup.html:98 +#: uffd/templates/mfa/setup.html:99 uffd/templates/mfa/setup.html:107 +#: 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/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 +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 +msgid "Description" +msgstr "Beschreibung" + +#: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27 +#: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8 +#: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15 +#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:22 +msgid "Save" +msgstr "Speichern" + +#: uffd/templates/group/show.html:9 uffd/templates/invite/new.html:56 +#: uffd/templates/mail/show.html:28 uffd/templates/mfa/auth.html:33 +#: uffd/templates/role/show.html:14 uffd/templates/rolemod/show.html:9 +#: uffd/templates/service/api.html:9 uffd/templates/service/oauth2.html:9 +#: uffd/templates/service/show.html:10 +#: uffd/templates/session/deviceauth.html:39 +#: uffd/templates/session/deviceauth.html:49 +#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:23 +msgid "Cancel" +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/service/oauth2.html:11 uffd/templates/service/show.html:12 +#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:166 +msgid "Are you sure?" +msgstr "Wirklich fortfahren?" + +#: uffd/templates/group/show.html:11 uffd/templates/group/show.html:13 +#: uffd/templates/mail/show.html:30 uffd/templates/mail/show.html:32 +#: uffd/templates/mfa/setup.html:117 uffd/templates/mfa/setup.html:179 +#: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24 +#: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12 +#: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13 +#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:27 +#: uffd/templates/user/show.html:100 +msgid "Delete" +msgstr "Löschen" + +#: uffd/templates/group/show.html:18 +msgid "Group ID" +msgstr "Gruppen ID" + +#: uffd/templates/group/show.html:29 uffd/templates/signup/start.html:18 +msgid "" +"At least one and at most 32 lower-case characters, digits, dashes (\"-\")" +" or underscores (\"_\"). <b>Cannot be changed later!</b>" +msgstr "" +"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche " +"(\"_\"). <b>Kann später nicht geändert werden!</b>" + +#: uffd/templates/group/show.html:40 uffd/templates/role/show.html:71 +#: uffd/templates/rolemod/show.html:16 +msgid "Members" +msgstr "Mitglieder" + +#: uffd/templates/invite/list.html:12 msgid "Link" msgstr "Link" -#: uffd/invite/templates/invite/list.html:13 +#: uffd/templates/invite/list.html:13 msgid "Created by" msgstr "Erstellt durch" -#: uffd/invite/templates/invite/list.html:14 -#: uffd/service/templates/service/api.html:34 -#: uffd/service/templates/service/show.html:85 +#: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34 +#: uffd/templates/service/show.html:85 msgid "Permissions" msgstr "Berechtigungen" -#: uffd/invite/templates/invite/list.html:15 +#: uffd/templates/invite/list.html:15 msgid "Usages" msgstr "Verwendungen" -#: uffd/invite/templates/invite/list.html:16 +#: uffd/templates/invite/list.html:16 msgid "Status" msgstr "Status" -#: uffd/invite/templates/invite/list.html:26 +#: uffd/templates/invite/list.html:26 msgid "Copy link to clipboard" msgstr "Link kopieren" -#: uffd/invite/templates/invite/list.html:27 +#: uffd/templates/invite/list.html:27 msgid "Show link as QR code" msgstr "Link als QR-Code anzeigen" -#: uffd/invite/templates/invite/list.html:40 +#: uffd/templates/invite/list.html:40 msgid "Signup" msgstr "Account-Registrierung" -#: uffd/invite/templates/invite/list.html:44 +#: uffd/templates/invite/list.html:44 msgid "user signups" msgstr "Account-Registrierungen" -#: uffd/invite/templates/invite/list.html:49 -#: uffd/user/templates/user/show.html:91 +#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:163 msgid "Disabled" msgstr "Deaktiviert" -#: uffd/invite/templates/invite/list.html:51 +#: uffd/templates/invite/list.html:51 msgid "Voided" msgstr "Verbraucht" -#: uffd/invite/templates/invite/list.html:53 +#: uffd/templates/invite/list.html:53 msgid "Expired" msgstr "Abgelaufen" -#: uffd/invite/templates/invite/list.html:55 +#: uffd/templates/invite/list.html:55 msgid "Invalid, unpermitted creator" msgstr "Ungültig, erstellt durch unberechtigten Account" -#: uffd/invite/templates/invite/list.html:57 +#: uffd/templates/invite/list.html:57 msgid "Invalid" msgstr "Ungültig" -#: uffd/invite/templates/invite/list.html:59 +#: uffd/templates/invite/list.html:59 #, python-format msgid "Valid once, expires %(expiry_date)s" msgstr "Einmal verwendbar, gültig bis %(expiry_date)s" -#: uffd/invite/templates/invite/list.html:61 +#: uffd/templates/invite/list.html:61 #, python-format msgid "Valid, expires %(expiry_date)s" msgstr "Gültig bis %(expiry_date)s" -#: uffd/invite/templates/invite/list.html:78 +#: uffd/templates/invite/list.html:78 msgid "Invite Link Details" msgstr "Details zum Einladungslink" -#: uffd/invite/templates/invite/list.html:85 +#: uffd/templates/invite/list.html:85 msgid "Type:" msgstr "Typ:" -#: uffd/invite/templates/invite/list.html:85 +#: uffd/templates/invite/list.html:85 msgid "Single-use" msgstr "Einmal verwendbar" -#: uffd/invite/templates/invite/list.html:85 -#: uffd/invite/templates/invite/new.html:9 +#: uffd/templates/invite/list.html:85 uffd/templates/invite/new.html:9 msgid "Multi-use" msgstr "Mehrfach verwendbar" -#: uffd/invite/templates/invite/list.html:86 +#: uffd/templates/invite/list.html:86 msgid "Created:" msgstr "Erstellt:" -#: uffd/invite/templates/invite/list.html:87 +#: uffd/templates/invite/list.html:87 msgid "Expires:" msgstr "Ablaufdatum:" -#: uffd/invite/templates/invite/list.html:88 +#: uffd/templates/invite/list.html:88 msgid "Permissions:" msgstr "Berechtigungen:" -#: uffd/invite/templates/invite/list.html:91 -#: uffd/invite/templates/invite/new.html:21 +#: uffd/templates/invite/list.html:91 uffd/templates/invite/new.html:21 msgid "Link allows account registration" msgstr "Link erlaubt Account-Registrierung" -#: uffd/invite/templates/invite/list.html:93 -#: uffd/invite/templates/invite/new.html:22 +#: uffd/templates/invite/list.html:93 uffd/templates/invite/new.html:22 msgid "No account registration allowed" msgstr "Keine Account-Registrierung möglich" -#: uffd/invite/templates/invite/list.html:96 +#: uffd/templates/invite/list.html:96 #, python-format msgid "Link grants users the role \"%(name)s\"" msgstr "Link gibt Accounts die Rolle \"%(name)s\"" -#: uffd/invite/templates/invite/list.html:102 +#: uffd/templates/invite/list.html:102 msgid "Never used" msgstr "Keine Verwendungen" -#: uffd/invite/templates/invite/list.html:106 +#: uffd/templates/invite/list.html:106 #, python-format msgid "Registration of user <a href=\"%(user_url)s\">%(user_name)s</a>" msgstr "Account-Registrierung von <a href=\"%(user_url)s\">%(user_name)s</a>" -#: uffd/invite/templates/invite/list.html:109 +#: uffd/templates/invite/list.html:109 #, python-format msgid "Roles granted to <a href=\"%(user_url)s\">%(user_name)s</a>" msgstr "Rollen an <a href=\"%(user_url)s\">%(user_name)s</a> vergeben" -#: uffd/invite/templates/invite/list.html:120 +#: uffd/templates/invite/list.html:120 msgid "Disable Link" msgstr "Link deaktivieren" -#: uffd/invite/templates/invite/list.html:124 +#: uffd/templates/invite/list.html:124 msgid "Reenable Link" msgstr "Link reaktivieren" -#: uffd/invite/templates/invite/list.html:138 +#: uffd/templates/invite/list.html:138 msgid "Invite" msgstr "Einladungslink" -#: uffd/invite/templates/invite/new.html:6 +#: uffd/templates/invite/new.html:6 msgid "Link Type" msgstr "Link Typ" -#: uffd/invite/templates/invite/new.html:8 +#: uffd/templates/invite/new.html:8 msgid "Valid for a single successful use" msgstr "Für eine erfolgreiche Verwendung gültig" -#: uffd/invite/templates/invite/new.html:13 +#: uffd/templates/invite/new.html:13 msgid "Valid Until" msgstr "Ablaufdatum" -#: uffd/invite/templates/invite/new.html:15 +#: uffd/templates/invite/new.html:15 #, python-format msgid "Must be within the next %(max_valid_days)d days" msgstr "Muss innerhalb der nächsten %(max_valid_days)d Tage liegen" -#: uffd/invite/templates/invite/new.html:19 -#: uffd/signup/templates/signup/start.html:6 +#: uffd/templates/invite/new.html:19 uffd/templates/signup/start.html:6 msgid "Account Registration" msgstr "Account-Registrierung" -#: uffd/invite/templates/invite/new.html:30 +#: uffd/templates/invite/new.html:30 msgid "Granted Roles" msgstr "Enthaltene Rollen" -#: uffd/invite/templates/invite/new.html:35 -#: uffd/mail/templates/mail/list.html:14 uffd/mail/templates/mail/show.html:7 -#: uffd/mfa/templates/mfa/setup.html:98 uffd/mfa/templates/mfa/setup.html:99 -#: uffd/mfa/templates/mfa/setup.html:107 uffd/mfa/templates/mfa/setup.html:157 -#: uffd/mfa/templates/mfa/setup.html:158 uffd/mfa/templates/mfa/setup.html:169 -#: uffd/role/templates/role/list.html:14 -#: uffd/rolemod/templates/rolemod/list.html:9 -#: uffd/rolemod/templates/rolemod/show.html:44 -#: uffd/selfservice/templates/selfservice/self.html:97 -#: uffd/service/templates/service/index.html:14 -#: uffd/service/templates/service/show.html:20 -#: uffd/service/templates/service/show.html:84 -#: uffd/user/templates/group/list.html:15 -#: uffd/user/templates/group/show.html:26 -#: uffd/user/templates/user/show.html:106 -#: uffd/user/templates/user/show.html:138 -msgid "Name" -msgstr "Name" - -#: uffd/invite/templates/invite/new.html:36 -#: uffd/role/templates/role/list.html:15 uffd/role/templates/role/show.html:48 -#: uffd/rolemod/templates/rolemod/list.html:10 -#: uffd/rolemod/templates/rolemod/show.html:26 -#: uffd/selfservice/templates/selfservice/self.html:98 -#: uffd/user/templates/group/list.html:16 -#: uffd/user/templates/group/show.html:33 -#: uffd/user/templates/user/show.html:107 -#: uffd/user/templates/user/show.html:139 -msgid "Description" -msgstr "Beschreibung" - -#: uffd/invite/templates/invite/new.html:55 +#: uffd/templates/invite/new.html:55 msgid "Create Link" msgstr "Link erstellen" -#: uffd/invite/templates/invite/new.html:56 -#: uffd/mail/templates/mail/show.html:28 uffd/mfa/templates/mfa/auth.html:33 -#: uffd/role/templates/role/show.html:14 -#: uffd/rolemod/templates/rolemod/show.html:9 -#: uffd/service/templates/service/api.html:9 -#: uffd/service/templates/service/oauth2.html:9 -#: uffd/service/templates/service/show.html:10 -#: uffd/session/templates/session/deviceauth.html:39 -#: uffd/session/templates/session/deviceauth.html:49 -#: uffd/session/templates/session/devicelogin.html:29 -#: uffd/user/templates/group/show.html:9 uffd/user/templates/user/show.html:8 -msgid "Cancel" -msgstr "Abbrechen" - -#: uffd/invite/templates/invite/use.html:5 +#: uffd/templates/invite/use.html:5 msgid "Invite Link" msgstr "Einladungslink" -#: uffd/invite/templates/invite/use.html:8 +#: uffd/templates/invite/use.html:8 #, python-format msgid "Welcome to the %(org_name)s Single-Sign-On!" msgstr "Willkommen im %(org_name)s Single-Sign-On!" -#: uffd/invite/templates/invite/use.html:12 +#: uffd/templates/invite/use.html:12 msgid "" "With this link you can register a new user account with the following " "roles or add the roles to an existing account:" @@ -352,62 +405,45 @@ msgstr "" "Mit diesem Link kannst du einen Account mit den folgenden Rollen " "erstellen oder diese Rollen zu einem existierenden Account hinzufügen:" -#: uffd/invite/templates/invite/use.html:14 +#: uffd/templates/invite/use.html:14 msgid "With this link you can add the following roles to an existing account:" msgstr "" "Mit diesem Link kannst du die folgenden Rollen zu einem existierenden " "Account hinzufügen:" -#: uffd/invite/templates/invite/use.html:16 +#: uffd/templates/invite/use.html:16 msgid "With this link you can register a new user account." msgstr "Mit diesem Link kannst du einen Account registieren." -#: uffd/invite/templates/invite/use.html:28 +#: uffd/templates/invite/use.html:28 msgid "Add the roles to your account now" msgstr "Rollen jetzt zu deinem Account hinzufügen" -#: uffd/invite/templates/invite/use.html:30 +#: uffd/templates/invite/use.html:30 msgid "Logout and switch to a different account" msgstr "Abmelden und zu einem anderen Account wechseln" -#: uffd/invite/templates/invite/use.html:33 +#: uffd/templates/invite/use.html:33 msgid "Logout to register a new account" msgstr "Abmelden um einen neuen Account zu registrieren" -#: uffd/invite/templates/invite/use.html:37 +#: uffd/templates/invite/use.html:37 msgid "Register a new account" msgstr "Neuen Account registrieren" -#: uffd/invite/templates/invite/use.html:40 +#: uffd/templates/invite/use.html:40 msgid "Login and add the roles to your account" msgstr "Anmelden und die Rollen zu deinem Account hinzufügen" -#: uffd/mail/views.py:22 -msgid "Forwardings" -msgstr "Weiterleitungen" - -#: uffd/mail/views.py:47 -#, python-format -msgid "Invalid receive address: %(mail_address)s" -msgstr "Ungültige Empfangsadresse: %(mail_address)s" - -#: uffd/mail/views.py:51 -msgid "Mail mapping updated." -msgstr "Mailweiterleitung geändert." - -#: uffd/mail/views.py:60 -msgid "Deleted mail mapping." -msgstr "Mailweiterleitung gelöscht." - -#: uffd/mail/templates/mail/list.html:15 uffd/mail/templates/mail/show.html:13 +#: uffd/templates/mail/list.html:15 uffd/templates/mail/show.html:13 msgid "Receiving addresses" msgstr "Empfangsadressen" -#: uffd/mail/templates/mail/list.html:16 uffd/mail/templates/mail/show.html:20 +#: uffd/templates/mail/list.html:16 uffd/templates/mail/show.html:20 msgid "Destinations" msgstr "Zieladressen" -#: uffd/mail/templates/mail/show.html:16 +#: uffd/templates/mail/show.html:16 msgid "" "One address pattern (local+ext@domain, local@domain, local, @domain) per " "line. Only lower-case ASCII letters, digits and symbols." @@ -415,167 +451,103 @@ msgstr "" "Ein Adressmuster (local+ext@domain, local@domain, local, @domain) pro " "Zeile. Nur ASCII-Kleinbuchstaben, -Ziffern und -Symbole." -#: uffd/mail/templates/mail/show.html:23 +#: uffd/templates/mail/show.html:23 msgid "One address per line" msgstr "Eine Adresse pro Zeile" -#: uffd/mail/templates/mail/show.html:27 uffd/role/templates/role/show.html:13 -#: uffd/rolemod/templates/rolemod/show.html:8 -#: uffd/service/templates/service/api.html:15 -#: uffd/service/templates/service/oauth2.html:15 -#: uffd/service/templates/service/show.html:16 -#: uffd/user/templates/group/show.html:8 uffd/user/templates/user/show.html:7 -msgid "Save" -msgstr "Speichern" - -#: uffd/mail/templates/mail/show.html:30 uffd/mail/templates/mail/show.html:32 -#: uffd/mfa/templates/mfa/setup.html:117 uffd/mfa/templates/mfa/setup.html:179 -#: uffd/role/templates/role/show.html:21 uffd/role/templates/role/show.html:24 -#: uffd/service/templates/service/api.html:12 -#: uffd/service/templates/service/oauth2.html:12 -#: uffd/service/templates/service/show.html:13 -#: uffd/user/templates/group/show.html:11 -#: uffd/user/templates/group/show.html:13 uffd/user/templates/user/show.html:10 -#: uffd/user/templates/user/show.html:12 -msgid "Delete" -msgstr "Löschen" - -#: uffd/mfa/views.py:51 -msgid "Two-factor authentication was reset" -msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt" - -#: uffd/mfa/views.py:80 -msgid "Generate recovery codes first!" -msgstr "Generiere zuerst die Wiederherstellungscodes!" - -#: uffd/mfa/views.py:88 -msgid "Code is invalid" -msgstr "Wiederherstellungscode ist ungültig" - -#: uffd/mfa/views.py:107 -#, python-format -msgid "" -"2FA WebAuthn support disabled because import of the fido2 module failed " -"(%s)" -msgstr "" -"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen " -"werden konnte (%s)" - -#: uffd/mfa/views.py:216 -#, python-format -msgid "We received too many invalid attempts! Please wait at least %s." -msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s." - -#: uffd/mfa/views.py:230 -msgid "You have exhausted your recovery codes. Please generate new ones now!" -msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!" - -#: uffd/mfa/views.py:233 -msgid "" -"You only have a few recovery codes remaining. Make sure to generate new " -"ones before they run out." -msgstr "" -"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere " -"diese erneut bevor keine mehr übrig sind." - -#: uffd/mfa/views.py:237 -msgid "Two-factor authentication failed" -msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen" - -#: uffd/mfa/templates/mfa/auth.html:6 -#: uffd/selfservice/templates/selfservice/self.html:67 -#: uffd/user/templates/user/show.html:89 +#: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:134 +#: uffd/templates/user/show.html:161 msgid "Two-Factor Authentication" msgstr "Zwei-Faktor-Authentifizierung" -#: uffd/mfa/templates/mfa/auth.html:11 +#: uffd/templates/mfa/auth.html:11 msgid "Enable javascript for authentication with U2F/FIDO2 devices" msgstr "Aktiviere Javascript zur Authentifizierung mit U2F/FIDO2 Geräten" -#: uffd/mfa/templates/mfa/auth.html:15 +#: uffd/templates/mfa/auth.html:15 msgid "Authentication with U2F/FIDO2 devices is not supported by your browser" msgstr "" "Authentifizierung mit U2F/FIDO2 Geräten wird von deinem Browser nicht " "unterstützt" -#: uffd/mfa/templates/mfa/auth.html:21 +#: uffd/templates/mfa/auth.html:21 msgid "Authenticate with U2F/FIDO2 device" msgstr "Authentifiziere dich mit einem U2F/FIDO2-Gerät" -#: uffd/mfa/templates/mfa/auth.html:24 +#: uffd/templates/mfa/auth.html:24 msgid "or" msgstr "oder" -#: uffd/mfa/templates/mfa/auth.html:27 +#: uffd/templates/mfa/auth.html:27 msgid "Code from your authenticator app or recovery code" msgstr "Code aus deiner Authentifikator-App oder Wiederherstellungscode" -#: uffd/mfa/templates/mfa/auth.html:30 +#: uffd/templates/mfa/auth.html:30 msgid "Verify" msgstr "Verifizieren" -#: uffd/mfa/templates/mfa/auth.html:43 uffd/mfa/templates/mfa/setup.html:199 +#: uffd/templates/mfa/auth.html:43 uffd/templates/mfa/setup.html:199 msgid "Contacting server" msgstr "Verbinde mit Server" -#: uffd/mfa/templates/mfa/auth.html:52 uffd/mfa/templates/mfa/auth.html:80 +#: uffd/templates/mfa/auth.html:52 uffd/templates/mfa/auth.html:80 msgid "Session timed out" msgstr "Sitzung abgelaufen" -#: uffd/mfa/templates/mfa/auth.html:54 +#: uffd/templates/mfa/auth.html:54 msgid "You have not registered any U2F/FIDO2 devices for your account" msgstr "Es sind keine U2F/FIDO2-Geräte für deinen Account registriert" -#: uffd/mfa/templates/mfa/auth.html:56 uffd/mfa/templates/mfa/setup.html:208 +#: uffd/templates/mfa/auth.html:56 uffd/templates/mfa/setup.html:208 msgid "Server error" msgstr "Serverfehler" -#: uffd/mfa/templates/mfa/auth.html:59 uffd/mfa/templates/mfa/setup.html:210 +#: uffd/templates/mfa/auth.html:59 uffd/templates/mfa/setup.html:210 msgid "Waiting for device" msgstr "Warte auf Gerät" -#: uffd/mfa/templates/mfa/auth.html:62 +#: uffd/templates/mfa/auth.html:62 msgid "Verifing response" msgstr "Überprüfe Antwort" -#: uffd/mfa/templates/mfa/auth.html:76 +#: uffd/templates/mfa/auth.html:76 msgid "Success, redirecting" msgstr "Erfolg, leite weiter" -#: uffd/mfa/templates/mfa/auth.html:82 uffd/mfa/templates/mfa/setup.html:228 +#: uffd/templates/mfa/auth.html:82 uffd/templates/mfa/setup.html:228 msgid "Invalid response from device" msgstr "Ungültige Antwort von Gerät" -#: uffd/mfa/templates/mfa/auth.html:88 +#: uffd/templates/mfa/auth.html:88 msgid "Authentication timed out, was aborted or not allowed" msgstr "Authentifikation abgelaufen, abgebrochen oder nicht erlaubt" -#: uffd/mfa/templates/mfa/auth.html:90 +#: uffd/templates/mfa/auth.html:90 msgid "Device is not registered for your account" msgstr "Gerät ist nicht für deinen Account registriert" -#: uffd/mfa/templates/mfa/auth.html:92 +#: uffd/templates/mfa/auth.html:92 msgid "Authentication was aborted" msgstr "Authentifikation abgebrochen" -#: uffd/mfa/templates/mfa/auth.html:94 uffd/mfa/templates/mfa/setup.html:240 -#: uffd/mfa/templates/mfa/setup.html:264 +#: uffd/templates/mfa/auth.html:94 uffd/templates/mfa/setup.html:240 +#: uffd/templates/mfa/setup.html:264 msgid "U2F and FIDO2 devices are not supported by your browser" msgstr "U2F- und FIDO2-Geräte werden vom Webbrowser nicht unterstüzt" -#: uffd/mfa/templates/mfa/auth.html:97 uffd/mfa/templates/mfa/setup.html:243 +#: uffd/templates/mfa/auth.html:97 uffd/templates/mfa/setup.html:243 msgid "Could not connect to server" msgstr "Verbindung zum Server fehlgeschlagen" -#: uffd/mfa/templates/mfa/auth.html:103 +#: uffd/templates/mfa/auth.html:103 msgid "Authentication failed " msgstr "Authentifikation fehlgeschlagen" -#: uffd/mfa/templates/mfa/auth.html:106 +#: uffd/templates/mfa/auth.html:106 msgid "Retry authenticate with U2F/FIDO2 device" msgstr "Authentifikation mit U2F/FIDO2-Gerät nochmal versuchen" -#: uffd/mfa/templates/mfa/disable.html:6 +#: uffd/templates/mfa/disable.html:6 msgid "" "When you proceed, all recovery codes, registered authenticator " "applications and devices will be invalidated.\n" @@ -587,23 +559,21 @@ msgstr "" "Wiederherstellungscodes generieren und das Setup der Anwendungen und " "Geräte erneut durchführen." -#: uffd/mfa/templates/mfa/disable.html:11 uffd/mfa/templates/mfa/setup.html:32 +#: uffd/templates/mfa/disable.html:11 uffd/templates/mfa/setup.html:32 msgid "Disable two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren" -#: uffd/mfa/templates/mfa/setup.html:18 -#: uffd/selfservice/templates/selfservice/self.html:73 +#: uffd/templates/mfa/setup.html:18 uffd/templates/selfservice/self.html:140 msgid "Two-factor authentication is currently <strong>enabled</strong>." msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>." -#: uffd/mfa/templates/mfa/setup.html:20 -#: uffd/selfservice/templates/selfservice/self.html:75 +#: uffd/templates/mfa/setup.html:20 uffd/templates/selfservice/self.html:142 msgid "Two-factor authentication is currently <strong>disabled</strong>." msgstr "" "Die Zwei-Faktor-Authentifizierung ist derzeit " "<strong>deaktiviert</strong>." -#: uffd/mfa/templates/mfa/setup.html:23 +#: uffd/templates/mfa/setup.html:23 msgid "" "You need to generate recovery codes and setup at least one authentication" " method to enable two-factor authentication." @@ -612,7 +582,7 @@ msgstr "" "Authentifizierungsmethode hinzufügen um Zwei-Faktor-Authentifizierung " "nutzen zu können." -#: uffd/mfa/templates/mfa/setup.html:25 +#: uffd/templates/mfa/setup.html:25 msgid "" "You need to setup at least one authentication method to enable two-factor" " authentication." @@ -620,17 +590,16 @@ msgstr "" "Du musst mindestens eine Authentifizierungsmethode hinzufügen um Zwei-" "Faktor-Authentifizierung nutzen zu können." -#: uffd/mfa/templates/mfa/setup.html:36 +#: uffd/templates/mfa/setup.html:36 msgid "Reset two-factor configuration" msgstr "Zwei-Faktor-Authentifizierung zurücksetzen" -#: uffd/mfa/templates/mfa/setup.html:46 -#: uffd/mfa/templates/mfa/setup_recovery.html:5 -#: uffd/user/templates/user/show.html:92 +#: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5 +#: uffd/templates/user/show.html:164 msgid "Recovery Codes" msgstr "Wiederherstellungscodes" -#: uffd/mfa/templates/mfa/setup.html:48 +#: uffd/templates/mfa/setup.html:48 msgid "" "Recovery codes allow you to login and setup new two-factor methods when " "you lost your registered second factor." @@ -638,7 +607,7 @@ msgstr "" "Wiederherstellungscodes erlauben die Anmeldung und das erneute Hinzufügen" " einer Zwei-Faktor-Methode, falls der Zweite Faktor verloren geht." -#: uffd/mfa/templates/mfa/setup.html:52 +#: uffd/templates/mfa/setup.html:52 msgid "" "You need to setup recovery codes before you can setup up authenticator " "apps or U2F/FIDO2 devices." @@ -646,33 +615,33 @@ msgstr "" "Du musst Wiederherstellungscodes generieren bevor du einen " "Authentifikator-App oder ein U2F/FIDO2 Gerät hinzufen kannst." -#: uffd/mfa/templates/mfa/setup.html:54 +#: uffd/templates/mfa/setup.html:54 msgid "Each code can only be used once." msgstr "Jeder Code kann nur einmal verwendet werden." -#: uffd/mfa/templates/mfa/setup.html:62 +#: uffd/templates/mfa/setup.html:62 msgid "Generate recovery codes to enable two-factor authentication" msgstr "" "Generiere Wiederherstellungscodes um die Zwei-Faktor-Authentifizierung zu" " aktivieren" -#: uffd/mfa/templates/mfa/setup.html:66 +#: uffd/templates/mfa/setup.html:66 msgid "Generate new recovery codes" msgstr "Generiere neue Wiederherstellungscodes" -#: uffd/mfa/templates/mfa/setup.html:75 +#: uffd/templates/mfa/setup.html:75 msgid "You have no remaining recovery codes." msgstr "Du hast keine Wiederherstellungscodes übrig." -#: uffd/mfa/templates/mfa/setup.html:85 uffd/user/templates/user/show.html:92 +#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:164 msgid "Authenticator Apps (TOTP)" msgstr "Authentifikator-Apps (TOTP)" -#: uffd/mfa/templates/mfa/setup.html:87 +#: uffd/templates/mfa/setup.html:87 msgid "Use an authenticator application on your mobile device as a second factor." msgstr "Nutze eine Authentifikator-App auf deinem Mobilgerät als zweiten Faktor." -#: uffd/mfa/templates/mfa/setup.html:90 +#: uffd/templates/mfa/setup.html:90 msgid "" "The authenticator app generates a 6-digit one-time code each time you " "login.\n" @@ -682,27 +651,27 @@ msgstr "" "jeden Login. Passende Apps sind kostenlos verfügbar für die meisten " "Mobilgeräte." -#: uffd/mfa/templates/mfa/setup.html:100 +#: uffd/templates/mfa/setup.html:100 msgid "Setup new app" msgstr "Neue App hinzufügen" -#: uffd/mfa/templates/mfa/setup.html:108 uffd/mfa/templates/mfa/setup.html:170 +#: uffd/templates/mfa/setup.html:108 uffd/templates/mfa/setup.html:170 msgid "Registered On" msgstr "Registriert am" -#: uffd/mfa/templates/mfa/setup.html:122 +#: uffd/templates/mfa/setup.html:122 msgid "No authenticator apps registered yet" msgstr "Bisher keine Authentifikator-Apps registriert" -#: uffd/mfa/templates/mfa/setup.html:134 uffd/user/templates/user/show.html:92 +#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:164 msgid "U2F and FIDO2 Devices" msgstr "U2F und FIDO2 Geräte" -#: uffd/mfa/templates/mfa/setup.html:136 +#: uffd/templates/mfa/setup.html:136 msgid "Use an U2F or FIDO2 compatible hardware security key as a second factor." msgstr "Nutze einen U2F oder FIDO2 kompatiblen Key als zweiten Faktor." -#: uffd/mfa/templates/mfa/setup.html:139 +#: uffd/templates/mfa/setup.html:139 msgid "" "U2F and FIDO2 devices are not supported by all browsers and can be " "particularly difficult to use on mobile\n" @@ -715,49 +684,49 @@ msgstr "" "wird dringend empfohlen ebenfalls eine Authentifikator-App " "hinzuzufügen</strong> um einen Login mit allen Browsern zu ermöglichen." -#: uffd/mfa/templates/mfa/setup.html:147 +#: uffd/templates/mfa/setup.html:147 msgid "U2F/FIDO2 support not enabled" msgstr "U2F/FIDO2 Unterstützung nicht aktiviert" -#: uffd/mfa/templates/mfa/setup.html:151 +#: uffd/templates/mfa/setup.html:151 msgid "Enable javascript in your browser to use U2F and FIDO2 devices!" msgstr "" "Aktiviere Javascript in deinem Browser, um U2F und FIDO2 Geräte nutzen zu" " können!" -#: uffd/mfa/templates/mfa/setup.html:161 +#: uffd/templates/mfa/setup.html:161 msgid "Setup new device" msgstr "Neues Gerät hinzufügen" -#: uffd/mfa/templates/mfa/setup.html:184 +#: uffd/templates/mfa/setup.html:184 msgid "No U2F/FIDO2 devices registered yet" msgstr "Bisher kein U2F/FIDO2 Gerät registriert" -#: uffd/mfa/templates/mfa/setup.html:207 +#: uffd/templates/mfa/setup.html:207 msgid "You need to generate recovery codes first" msgstr "Du musst erst Wiederherstellungscodes generieren" -#: uffd/mfa/templates/mfa/setup.html:234 +#: uffd/templates/mfa/setup.html:234 msgid "Registration timed out, was aborted or not allowed" msgstr "Registrierung abgelaufen, abgebrochen oder nicht erlaubt" -#: uffd/mfa/templates/mfa/setup.html:236 +#: uffd/templates/mfa/setup.html:236 msgid "Device already registered" msgstr "Gerät bereits registriert" -#: uffd/mfa/templates/mfa/setup.html:238 +#: uffd/templates/mfa/setup.html:238 msgid "Registration was aborted" msgstr "Registrierung abgebrochen" -#: uffd/mfa/templates/mfa/setup.html:249 +#: uffd/templates/mfa/setup.html:249 msgid "Registration failed" msgstr "Registrierung fehlgeschlagen" -#: uffd/mfa/templates/mfa/setup.html:252 +#: uffd/templates/mfa/setup.html:252 msgid "Retry registration" msgstr "Registrierung nochmal versuchen" -#: uffd/mfa/templates/mfa/setup_recovery.html:8 +#: uffd/templates/mfa/setup_recovery.html:8 msgid "" "Recovery codes allow you to login when you lose access to your " "authenticator app or U2F/FIDO device. Each code can\n" @@ -767,7 +736,7 @@ msgstr "" "Authentifikator-App oder das U2F/FIDO2 Gerät verloren geht. Jeder Code " "kann nur einmal verwendet werden." -#: uffd/mfa/templates/mfa/setup_recovery.html:21 +#: uffd/templates/mfa/setup_recovery.html:21 msgid "" "These are your new recovery codes. Make sure to store them in a safe " "place or you risk losing access to your\n" @@ -777,21 +746,21 @@ msgstr "" "Ort, sonst könntest du den Zugriff auf dein Konto verlieren. Alle " "vorherigen Wiederherstellungscodes sind nun ungültig." -#: uffd/mfa/templates/mfa/setup_recovery.html:26 -#: uffd/session/templates/session/deviceauth.html:36 -#: uffd/session/templates/session/devicelogin.html:25 +#: uffd/templates/mfa/setup_recovery.html:26 +#: uffd/templates/session/deviceauth.html:36 +#: uffd/templates/session/devicelogin.html:25 msgid "Continue" msgstr "Weiter" -#: uffd/mfa/templates/mfa/setup_recovery.html:28 +#: uffd/templates/mfa/setup_recovery.html:28 msgid "Download codes" msgstr "Codes herunterladen" -#: uffd/mfa/templates/mfa/setup_recovery.html:30 +#: uffd/templates/mfa/setup_recovery.html:30 msgid "Print codes" msgstr "Codes ausdrucken" -#: uffd/mfa/templates/mfa/setup_totp.html:6 +#: uffd/templates/mfa/setup_totp.html:6 msgid "" "Install an authenticator application on your mobile device like FreeOTP " "or Google Authenticator and scan this QR\n" @@ -801,7 +770,7 @@ msgstr "" "oder Google Authenticator and scanne diesen QR Code. Auf Geräten von " "Apple kann die App \"Authenticator\" verwendet werden." -#: uffd/mfa/templates/mfa/setup_totp.html:18 +#: uffd/templates/mfa/setup_totp.html:18 msgid "" "If you are on your mobile device and cannot scan the code, you can click " "on it to open it with your\n" @@ -814,86 +783,51 @@ msgstr "" " öffnen. Wenn das nicht funktioniert, gib die folgenden Angaben manuell " "in die Authentifikator-App ein:" -#: uffd/mfa/templates/mfa/setup_totp.html:23 +#: uffd/templates/mfa/setup_totp.html:23 msgid "Issuer" msgstr "Herausgeber" -#: uffd/mfa/templates/mfa/setup_totp.html:24 +#: uffd/templates/mfa/setup_totp.html:24 msgid "Account" msgstr "Konto" -#: uffd/mfa/templates/mfa/setup_totp.html:25 +#: uffd/templates/mfa/setup_totp.html:25 msgid "Secret" msgstr "Geheimnis" -#: uffd/mfa/templates/mfa/setup_totp.html:26 +#: uffd/templates/mfa/setup_totp.html:26 msgid "Type" msgstr "Typ" -#: uffd/mfa/templates/mfa/setup_totp.html:27 +#: uffd/templates/mfa/setup_totp.html:27 msgid "Digits" msgstr "Zeichen" -#: uffd/mfa/templates/mfa/setup_totp.html:28 +#: uffd/templates/mfa/setup_totp.html:28 msgid "Hash algorithm" msgstr "Hash-Algorithmus" -#: uffd/mfa/templates/mfa/setup_totp.html:29 +#: uffd/templates/mfa/setup_totp.html:29 msgid "Interval/period" msgstr "Intervall/Dauer" -#: uffd/mfa/templates/mfa/setup_totp.html:29 +#: uffd/templates/mfa/setup_totp.html:29 msgid "seconds" msgstr "Sekunden" -#: uffd/mfa/templates/mfa/setup_totp.html:37 +#: uffd/templates/mfa/setup_totp.html:37 msgid "Code" msgstr "Code" -#: uffd/mfa/templates/mfa/setup_totp.html:38 +#: uffd/templates/mfa/setup_totp.html:38 msgid "Verify and complete setup" msgstr "Verifiziere und beende das Setup" -#: uffd/oauth2/views.py:169 uffd/selfservice/views.py:71 -#: uffd/session/views.py:74 -#, python-format -msgid "" -"We received too many requests from your ip address/network! Please wait " -"at least %(delay)s." -msgstr "" -"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem " -"Netzwerk empfangen! Bitte warte mindestens %(delay)s." - -#: uffd/oauth2/views.py:177 -msgid "Device login is currently not available. Try again later!" -msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!" - -#: uffd/oauth2/views.py:190 -msgid "Device login failed" -msgstr "Gerätelogin fehlgeschlagen" - -#: uffd/oauth2/views.py:196 -msgid "You need to login to access this service" -msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" - -#: uffd/oauth2/views.py:203 -#, python-format -msgid "" -"You don't have the permission to access the service " -"<b>%(service_name)s</b>." -msgstr "" -"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> " -"zuzugreifen." - -#: uffd/oauth2/templates/oauth2/logout.html:5 uffd/templates/base.html:99 -msgid "Logout" -msgstr "Abmelden" - -#: uffd/oauth2/templates/oauth2/logout.html:10 +#: uffd/templates/oauth2/logout.html:10 msgid "Javascript is required for automatic logout" msgstr "Für das automatische Abmelden muss Javascript aktiviert sein" -#: uffd/oauth2/templates/oauth2/logout.html:12 +#: uffd/templates/oauth2/logout.html:12 msgid "" "While you successfully logged out of the Single-Sign-On service, you may " "still be logged in on these services:" @@ -901,7 +835,7 @@ msgstr "" "Während du nun aus dem Single-Sign-On abgemeldet bist, bist du eventuell " "weiterhin in folgenden Diensten angemeldet:" -#: uffd/oauth2/templates/oauth2/logout.html:25 +#: uffd/templates/oauth2/logout.html:25 msgid "" "Please wait until you have been automatically logged out of all services " "or make sure of this yourself." @@ -909,39 +843,29 @@ msgstr "" "Bitte warte, bis das automatische Abmelden bei allen Diensten " "abgeschlossen ist oder melde dich überall manuell ab." -#: uffd/oauth2/templates/oauth2/logout.html:29 +#: uffd/templates/oauth2/logout.html:29 msgid "Logging you out on all services ..." msgstr "Abmeldung bei allen Diensten ..." -#: uffd/oauth2/templates/oauth2/logout.html:33 +#: uffd/templates/oauth2/logout.html:33 msgid "Skip this and continue" msgstr "Automatisches Abmelden überspringen" -#: uffd/oauth2/templates/oauth2/logout.html:72 +#: uffd/templates/oauth2/logout.html:72 msgid "Done, redirecting ..." msgstr "Abgeschlossen, leite weiter ..." -#: uffd/oauth2/templates/oauth2/logout.html:76 +#: uffd/templates/oauth2/logout.html:76 msgid "Log out failed on some services. Retry?" msgstr "" "Automatisches Abmelden bei einigen Diensten fehlgeschlagen. Nochmal " "versuchen?" -#: uffd/role/views.py:48 uffd/selfservice/templates/selfservice/self.html:86 -#: uffd/user/templates/user/list.html:20 uffd/user/templates/user/show.html:20 -#: uffd/user/templates/user/show.html:101 -msgid "Roles" -msgstr "Rollen" - -#: uffd/role/views.py:95 -msgid "Locked roles cannot be deleted" -msgstr "Gesperrte Rollen können nicht gelöscht werden" - -#: uffd/role/templates/role/list.html:23 +#: uffd/templates/role/list.html:23 msgid "<empty name>" msgstr "<leerer Name>" -#: uffd/role/templates/role/show.html:6 +#: uffd/templates/role/show.html:6 msgid "" "Name, moderator group, included roles and groups of this role are managed" " externally." @@ -949,7 +873,7 @@ msgstr "" "Name, Moderationsgruppe, enthaltene Rollen und Gruppen dieser Rolle " "werden extern verwaltet." -#: uffd/role/templates/role/show.html:17 +#: uffd/templates/role/show.html:17 msgid "" "All non-service users will be removed as members from this role and get " "its permissions implicitly. Are you sure?" @@ -957,218 +881,106 @@ msgstr "" "Alle Nicht-Service-Accounts verlieren diese Rolle und erhalten dessen " "Berechtigungen implizit." -#: uffd/role/templates/role/show.html:17 uffd/role/templates/role/show.html:23 +#: uffd/templates/role/show.html:17 uffd/templates/role/show.html:23 msgid "Set as default" msgstr "Als Default setzen" -#: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21 -#: uffd/selfservice/templates/selfservice/self.html:112 -#: uffd/service/templates/service/api.html:11 -#: uffd/service/templates/service/oauth2.html:11 -#: uffd/service/templates/service/show.html:12 -#: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:10 -#: uffd/user/templates/user/show.html:94 -msgid "Are you sure?" -msgstr "Wirklich fortfahren?" - -#: uffd/role/templates/role/show.html:19 +#: uffd/templates/role/show.html:19 msgid "Unset as default" msgstr "Nicht mehr als Default setzen" -#: uffd/role/templates/role/show.html:29 +#: uffd/templates/role/show.html:29 msgid "Settings" msgstr "Einstellungen" -#: uffd/role/templates/role/show.html:32 +#: uffd/templates/role/show.html:32 msgid "Included roles" msgstr "Enthaltene Rollen" -#: uffd/role/templates/role/show.html:35 uffd/role/templates/role/show.html:122 +#: uffd/templates/role/show.html:35 uffd/templates/role/show.html:122 msgid "Included groups" msgstr "Enthaltene Gruppen" -#: uffd/role/templates/role/show.html:42 +#: uffd/templates/role/show.html:42 msgid "Role Name" msgstr "Rollenname" -#: uffd/role/templates/role/show.html:54 +#: uffd/templates/role/show.html:54 msgid "Moderator Group" msgstr "Moderationsgruppe" -#: uffd/role/templates/role/show.html:56 +#: uffd/templates/role/show.html:56 msgid "No Moderator Group" msgstr "Keine Moderationsgruppe" -#: uffd/role/templates/role/show.html:63 +#: uffd/templates/role/show.html:63 msgid "Moderators" msgstr "Accounts mit Moderationsrechten" -#: uffd/role/templates/role/show.html:71 -#: uffd/rolemod/templates/rolemod/show.html:16 -#: uffd/user/templates/group/show.html:40 -msgid "Members" -msgstr "Mitglieder" - -#: uffd/role/templates/role/show.html:81 +#: uffd/templates/role/show.html:81 msgid "Roles to include groups from recursively" msgstr "Rollen, deren Gruppen rekursiv enthalten sein sollen" -#: uffd/role/templates/role/show.html:86 uffd/role/templates/role/show.html:127 +#: uffd/templates/role/show.html:86 uffd/templates/role/show.html:127 msgid "name" msgstr "Name" -#: uffd/role/templates/role/show.html:87 uffd/role/templates/role/show.html:128 +#: uffd/templates/role/show.html:87 uffd/templates/role/show.html:128 msgid "description" msgstr "Beschreibung" -#: uffd/role/templates/role/show.html:88 +#: uffd/templates/role/show.html:88 msgid "currently includes groups" msgstr "derzeit enthaltene Gruppen" -#: uffd/role/templates/role/show.html:129 +#: uffd/templates/role/show.html:129 msgid "2FA required" msgstr "2FA erforderlich" -#: uffd/rolemod/views.py:23 -msgid "Moderation" -msgstr "Moderation" - -#: uffd/rolemod/views.py:43 -msgid "Description too long" -msgstr "Beschreibung zu lang" - -#: uffd/rolemod/views.py:60 -msgid "Member removed" -msgstr "Mitglied entfernt" - -#: uffd/rolemod/templates/rolemod/show.html:7 +#: uffd/templates/rolemod/show.html:7 msgid "Invite Members" msgstr "Mitglieder einladen" -#: uffd/rolemod/templates/rolemod/show.html:13 +#: uffd/templates/rolemod/show.html:13 msgid "Overview" msgstr "Übersicht" -#: uffd/rolemod/templates/rolemod/show.html:22 +#: uffd/templates/rolemod/show.html:22 msgid "Role name" msgstr "Rollenname" -#: uffd/rolemod/templates/rolemod/show.html:30 +#: uffd/templates/rolemod/show.html:30 msgid "Moderators:" msgstr "Accounts mit Moderationsrechten:" -#: uffd/rolemod/templates/rolemod/show.html:40 +#: uffd/templates/rolemod/show.html:40 msgid "Role members:" msgstr "Mitglieder:" -#: uffd/rolemod/templates/rolemod/show.html:53 +#: uffd/templates/rolemod/show.html:53 msgid "Remove" msgstr "Entfernen" -#: uffd/selfservice/views.py:24 -msgid "Selfservice" -msgstr "Selfservice" - -#: uffd/selfservice/views.py:35 -msgid "Display name changed." -msgstr "Anzeigename geändert." +#: uffd/templates/selfservice/forgot_password.html:6 +msgid "Forgot password" +msgstr "Passwort vergessen" -#: uffd/selfservice/views.py:37 -msgid "Display name is not valid." -msgstr "Anzeigename ist nicht valide." +#: uffd/templates/selfservice/forgot_password.html:9 +#: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12 +#: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18 +#: uffd/templates/user/show.html:62 +msgid "Login Name" +msgstr "Anmeldename" -#: uffd/selfservice/views.py:40 -msgid "We sent you an email, please verify your mail address." -msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse." +#: uffd/templates/selfservice/forgot_password.html:13 +msgid "Mail Address" +msgstr "E-Mail-Adresse" -#: uffd/selfservice/views.py:52 -msgid "Password changed" -msgstr "Passwort geändert" +#: uffd/templates/selfservice/forgot_password.html:17 +msgid "Send password reset mail" +msgstr "Passwort-Zurücksetzen-Mail versenden" -#: uffd/selfservice/views.py:54 uffd/signup/models.py:85 -msgid "Invalid password" -msgstr "Passwort ungültig" - -#: uffd/selfservice/views.py:69 -#, python-format -msgid "" -"We received too many password reset requests for this user! Please wait " -"at least %(delay)s." -msgstr "" -"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! " -"Bitte warte mindestens %(delay)s." - -#: uffd/selfservice/views.py:75 -msgid "" -"We sent a mail to this user's mail address if you entered the correct " -"mail and login name combination" -msgstr "" -"Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an " -"die Adresse gesendet." - -#: uffd/selfservice/views.py:86 uffd/selfservice/views.py:112 -msgid "Link invalid or expired" -msgstr "Link ist ungültig oder abgelaufen" - -#: uffd/selfservice/views.py:91 -msgid "You need to set a password, please try again." -msgstr "Password fehlt, bitte versuche es erneut." - -#: uffd/selfservice/views.py:94 -msgid "Passwords do not match, please try again." -msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut" - -#: uffd/selfservice/views.py:99 -msgid "Password ist not valid, please try again." -msgstr "Ungültiges Passwort, bitte versuche es erneut" - -#: uffd/selfservice/views.py:103 -msgid "New password set" -msgstr "Passwort geändert" - -#: uffd/selfservice/views.py:115 -msgid "" -"This link was generated for another user. Login as the correct user to " -"continue." -msgstr "" -"Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem " -"richtigen Account an um Fortzufahren." - -#: uffd/selfservice/views.py:119 -msgid "New mail set" -msgstr "E-Mail-Adresse geändert" - -#: uffd/selfservice/views.py:130 -#, python-format -msgid "You left role %(role_name)s" -msgstr "Rolle %(role_name)s verlassen" - -#: uffd/selfservice/views.py:140 uffd/selfservice/views.py:156 -#, python-format -msgid "Mail to \"%(mail_address)s\" could not be sent!" -msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" - -#: uffd/selfservice/templates/selfservice/forgot_password.html:6 -msgid "Forgot password" -msgstr "Passwort vergessen" - -#: uffd/selfservice/templates/selfservice/forgot_password.html:9 -#: uffd/selfservice/templates/selfservice/self.html:21 -#: uffd/session/templates/session/login.html:12 -#: uffd/signup/templates/signup/start.html:9 -#: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:47 -msgid "Login Name" -msgstr "Anmeldename" - -#: uffd/selfservice/templates/selfservice/forgot_password.html:13 -msgid "Mail Address" -msgstr "E-Mail-Adresse" - -#: uffd/selfservice/templates/selfservice/forgot_password.html:17 -msgid "Send password reset mail" -msgstr "Passwort-Zurücksetzen-Mail versenden" - -#: uffd/selfservice/templates/selfservice/self.html:7 +#: uffd/templates/selfservice/self.html:7 msgid "" "Some permissions require you to setup two-factor authentication.\n" "\tThese permissions are not in effect until you do that." @@ -1177,12 +989,11 @@ msgstr "" "Authentifizierung.\n" "\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast." -#: uffd/selfservice/templates/selfservice/self.html:14 -#: uffd/user/templates/user/show.html:17 +#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:32 msgid "Profile" msgstr "Profil" -#: uffd/selfservice/templates/selfservice/self.html:15 +#: 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 " @@ -1192,39 +1003,99 @@ msgstr "" "Single-Sign-On angeschlossen sind. Die E-Mail-Adresse wird außerdem für " "die „Passwort vergessen“ genutzt." -#: uffd/selfservice/templates/selfservice/self.html:16 +#: uffd/templates/selfservice/self.html:16 msgid "Changes may take several minutes to be visible in all services." msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar." -#: uffd/selfservice/templates/selfservice/self.html:25 -#: uffd/signup/templates/signup/start.html:22 -#: uffd/user/templates/user/list.html:19 uffd/user/templates/user/show.html:62 +#: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22 +#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:77 msgid "Display Name" msgstr "Anzeigename" -#: uffd/selfservice/templates/selfservice/self.html:29 -#: uffd/signup/templates/signup/start.html:29 -msgid "E-Mail Address" -msgstr "E-Mail-Adresse" +#: uffd/templates/selfservice/self.html:28 +msgid "Update Profile" +msgstr "Änderungen speichern" + +#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:94 +msgid "E-Mail Addresses" +msgstr "E-Mail-Adressen" -#: uffd/selfservice/templates/selfservice/self.html:32 -msgid "We will send you a confirmation mail to this address if you change it" +#: uffd/templates/selfservice/self.html:38 +msgid "" +"Add and delete addresses associated with your account. You will need to " +"verify new addresses by opening a link set to them." msgstr "" -"Wir werden dir eine Bestätigungsmail zum Setzen der neuen E-Mail-Adresse " -"senden." +"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." -#: uffd/selfservice/templates/selfservice/self.html:35 -msgid "Update Profile" -msgstr "Änderungen speichern" +#: uffd/templates/selfservice/self.html:43 +msgid "Email" +msgstr "E-Mail" + +#: uffd/templates/selfservice/self.html:44 +msgid "New E-Mail Address" +msgstr "Neue E-Mail-Adresse" + +#: uffd/templates/selfservice/self.html:45 +msgid "Add address" +msgstr "Adresse hinzufügen" + +#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:110 +msgid "primary" +msgstr "primär" + +#: uffd/templates/selfservice/self.html:57 +msgid "unverified" +msgstr "unverifiziert" + +#: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:171 +msgid "Cannot delete primary e-mail address" +msgstr "Primäre E-Mail-Adresse kann nicht gelöscht werden" + +#: uffd/templates/selfservice/self.html:65 +msgid "Retry verification" +msgstr "Verifikation neustarten" + +#: uffd/templates/selfservice/self.html:79 +msgid "E-Mail Preferences" +msgstr "E-Mail-Einstellungen" + +#: uffd/templates/selfservice/self.html:80 +msgid "" +"Choose which of your verified address to use as your primary or recovery " +"address." +msgstr "" +"Wähle aus deinen verifizierten Adressen die primäre Adresse und die " +"Wiederherstellungsadresse." + +#: uffd/templates/selfservice/self.html:85 +msgid "Primary Address" +msgstr "Primäre Adresse" + +#: uffd/templates/selfservice/self.html:93 +msgid "Recovery Address" +msgstr "Wiederherstellungsadresse" + +#: uffd/templates/selfservice/self.html:95 uffd/templates/user/show.html:139 +msgid "Use primary address" +msgstr "Verwende primäre Adresse" -#: uffd/selfservice/templates/selfservice/self.html:44 -#: uffd/session/templates/session/login.html:16 -#: uffd/signup/templates/signup/start.html:36 -#: uffd/user/templates/user/show.html:76 +#: 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:102 +msgid "Update E-Mail Preferences" +msgstr "E-Mail-Einstellungen speichern" + +#: uffd/templates/selfservice/self.html:111 +#: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36 +#: uffd/templates/user/show.html:148 msgid "Password" msgstr "Passwort" -#: uffd/selfservice/templates/selfservice/self.html:45 +#: uffd/templates/selfservice/self.html:112 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 " @@ -1235,22 +1106,22 @@ msgstr "" " Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für" " Support-Anfragen benötigt." -#: uffd/selfservice/templates/selfservice/self.html:50 -#: uffd/selfservice/templates/selfservice/set_password.html:9 +#: uffd/templates/selfservice/self.html:117 +#: uffd/templates/selfservice/set_password.html:9 msgid "New Password" msgstr "Neues Passwort" -#: uffd/selfservice/templates/selfservice/self.html:56 -#: uffd/selfservice/templates/selfservice/set_password.html:16 -#: uffd/signup/templates/signup/start.html:43 +#: uffd/templates/selfservice/self.html:123 +#: uffd/templates/selfservice/set_password.html:16 +#: uffd/templates/signup/start.html:43 msgid "Repeat Password" msgstr "Passwort wiederholen" -#: uffd/selfservice/templates/selfservice/self.html:58 +#: uffd/templates/selfservice/self.html:125 msgid "Change Password" msgstr "Passwort ändern" -#: uffd/selfservice/templates/selfservice/self.html:68 +#: uffd/templates/selfservice/self.html:135 msgid "" "Setting up Two-Factor Authentication (2FA) adds an additional step to the" " Single-Sign-On login and increases the security of your account " @@ -1260,11 +1131,17 @@ msgstr "" "Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit " "deines Accounts erheblich." -#: uffd/selfservice/templates/selfservice/self.html:78 +#: uffd/templates/selfservice/self.html:145 msgid "Manage two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" -#: uffd/selfservice/templates/selfservice/self.html:87 +#: 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/views/role.py:21 +msgid "Roles" +msgstr "Rollen" + +#: uffd/templates/selfservice/self.html:154 msgid "" "Aside from a set of base permissions, your roles determine the " "permissions of your account." @@ -1272,7 +1149,7 @@ msgstr "" "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, " "von deinen Rollen bestimmt" -#: uffd/selfservice/templates/selfservice/self.html:89 +#: uffd/templates/selfservice/self.html:156 #, python-format msgid "" "See <a href=\"%(services_url)s\">Services</a> for an overview of your " @@ -1281,13 +1158,13 @@ msgstr "" "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick " "über deine aktuellen Berechtigungen." -#: uffd/selfservice/templates/selfservice/self.html:93 +#: uffd/templates/selfservice/self.html:160 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/selfservice/templates/selfservice/self.html:107 +#: uffd/templates/selfservice/self.html:175 msgid "" "Some permissions in this role require you to setup two-factor " "authentication" @@ -1295,83 +1172,81 @@ msgstr "" "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-" "Faktor-Authentifikation" -#: uffd/selfservice/templates/selfservice/self.html:113 +#: uffd/templates/selfservice/self.html:181 msgid "Leave" msgstr "Verlassen" -#: uffd/selfservice/templates/selfservice/self.html:120 +#: uffd/templates/selfservice/self.html:188 msgid "You currently don't have any roles" msgstr "Du hast derzeit keine Rollen" -#: uffd/selfservice/templates/selfservice/set_password.html:6 +#: uffd/templates/selfservice/set_password.html:6 msgid "Reset password" msgstr "Passwort zurücksetzen" -#: uffd/selfservice/templates/selfservice/set_password.html:20 +#: uffd/templates/selfservice/set_password.html:20 msgid "Set password" msgstr "Passwort setzen" -#: uffd/service/views.py:34 -msgid "Services" -msgstr "Dienste" - -#: uffd/service/templates/service/api.html:20 +#: uffd/templates/service/api.html:20 msgid "Authentication Username" msgstr "Authentifikations-Name" -#: uffd/service/templates/service/api.html:25 +#: uffd/templates/service/api.html:25 msgid "Authentication Password" msgstr "Authentifikations-Passwort" -#: uffd/service/templates/service/api.html:37 +#: uffd/templates/service/api.html:37 msgid "Access user and group data" msgstr "Zugriff auf Account- und Gruppen-Daten" -#: uffd/service/templates/service/api.html:41 +#: uffd/templates/service/api.html:41 msgid "Verify user passwords" msgstr "Passwörter von Nutzeraccounts verifizieren" -#: uffd/service/templates/service/api.html:45 +#: uffd/templates/service/api.html:45 msgid "Access mail aliases" msgstr "Zugriff auf Mail-Weiterleitungen" -#: uffd/service/templates/service/api.html:49 +#: uffd/templates/service/api.html:49 msgid "Resolve remailer addresses" msgstr "Auflösen von Remailer-Adressen" -#: uffd/service/templates/service/api.html:51 -#: uffd/service/templates/service/show.html:38 +#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:38 msgid "This option has no effect: Remailer config options are unset" msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" -#: uffd/service/templates/service/oauth2.html:20 -#: uffd/service/templates/service/show.html:56 +#: uffd/templates/service/api.html:56 +msgid "Access uffd metrics" +msgstr "Zugriff auf uffd-Metriken" + +#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:56 msgid "Client ID" msgstr "Client-ID" -#: uffd/service/templates/service/oauth2.html:25 +#: uffd/templates/service/oauth2.html:25 msgid "Client Secret" msgstr "Client-Secret" -#: uffd/service/templates/service/oauth2.html:34 +#: uffd/templates/service/oauth2.html:34 msgid "Redirect URIs" msgstr "Redirect-URIs" -#: uffd/service/templates/service/oauth2.html:37 +#: uffd/templates/service/oauth2.html:37 msgid "One URI per line" msgstr "Eine URI pro Zeile" -#: uffd/service/templates/service/oauth2.html:42 +#: uffd/templates/service/oauth2.html:42 msgid "Logout URIs" msgstr "Abmelde-URIs" -#: uffd/service/templates/service/oauth2.html:49 +#: uffd/templates/service/oauth2.html:49 msgid "One URI per line, prefixed with space-separated method (GET/POST)" msgstr "" "Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-" "Methode (GET/POST)" -#: uffd/service/templates/service/overview.html:11 +#: uffd/templates/service/overview.html:11 msgid "" "Some services may not be publicly listed! Log in to see all services you " "have access to." @@ -1379,106 +1254,62 @@ msgstr "" "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an" " um alle Dienste zu sehen, auf die du Zugriff hast." -#: uffd/service/templates/service/overview.html:15 -#: uffd/session/templates/session/login.html:6 -#: uffd/session/templates/session/login.html:20 uffd/templates/base.html:106 -msgid "Login" -msgstr "Anmelden" - -#: uffd/service/templates/service/overview.html:36 +#: uffd/templates/service/overview.html:36 #, python-format msgid "Logo for %(service_title)s" msgstr "Logo für %(service_title)s" -#: uffd/service/templates/service/overview.html:55 +#: uffd/templates/service/overview.html:55 msgid "No access" msgstr "Kein Zugriff" -#: uffd/service/templates/service/overview.html:75 +#: uffd/templates/service/overview.html:75 msgid "Manage OAuth2 and API clients" msgstr "OAuth2- und API-Clients verwalten" -#: uffd/service/templates/service/overview.html:95 -#: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79 +#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:58 +#: uffd/templates/user/list.html:79 msgid "Close" msgstr "Schließen" -#: uffd/service/templates/service/show.html:24 +#: uffd/templates/service/show.html:24 msgid "Access Restriction" msgstr "Zugriffsbeschränkungen" -#: uffd/service/templates/service/show.html:26 +#: uffd/templates/service/show.html:26 msgid "No user has access" msgstr "Kein Account hat Zugriff" -#: uffd/service/templates/service/show.html:27 +#: uffd/templates/service/show.html:27 msgid "All users have access (legacy)" msgstr "Alle Account haben Zugriff (veraltet)" -#: uffd/service/templates/service/show.html:29 +#: uffd/templates/service/show.html:29 #, python-format msgid "Members of group \"%(group_name)s\" have access" msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" -#: uffd/service/templates/service/show.html:36 +#: uffd/templates/service/show.html:36 msgid "Hide mail addresses with remailer" msgstr "Verstecke Mailadressen mit dem Remailer" -#: uffd/session/views.py:72 -#, python-format -msgid "" -"We received too many invalid login attempts for this user! Please wait at" -" least %(delay)s." -msgstr "" -"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account " -"erhalten! Bitte warte mindestens %(delay)s." - -#: uffd/session/views.py:80 -msgid "Login name or password is wrong" -msgstr "Der Anmeldename oder das Passwort ist falsch" - -#: uffd/session/views.py:86 -msgid "You do not have access to this service" -msgstr "Du hast keinen Zugriff auf diesen Service" - -#: uffd/session/views.py:98 uffd/session/views.py:109 -msgid "You need to login first" -msgstr "Du musst dich erst anmelden" - -#: uffd/session/views.py:130 uffd/session/views.py:140 -msgid "Initiation code is no longer valid" -msgstr "Startcode ist nicht mehr gültig" - -#: uffd/session/views.py:144 -msgid "Invalid confirmation code" -msgstr "Ungültiger Bestätigungscode" - -#: uffd/session/views.py:156 uffd/session/views.py:167 -msgid "Invalid initiation code" -msgstr "Ungültiger Startcode" - -#: uffd/session/templates/session/deviceauth.html:12 -#: uffd/templates/base.html:92 -msgid "Authorize Device Login" -msgstr "Gerätelogin erlauben" - -#: uffd/session/templates/session/deviceauth.html:15 +#: uffd/templates/session/deviceauth.html:15 msgid "Log into a service on another device without entering your password." msgstr "" "Melde dich an einem Dienst auf einem anderen Gerät an ohne dein Password " "eingeben zu müssen." -#: uffd/session/templates/session/deviceauth.html:18 -#: uffd/session/templates/session/devicelogin.html:13 +#: uffd/templates/session/deviceauth.html:18 +#: uffd/templates/session/devicelogin.html:13 msgid "Initiation Code" msgstr "Startcode" -#: uffd/session/templates/session/deviceauth.html:27 -#: uffd/session/templates/session/devicelogin.html:18 +#: uffd/templates/session/deviceauth.html:27 +#: uffd/templates/session/devicelogin.html:18 msgid "Confirmation Code" msgstr "Bestätigungscode" -#: uffd/session/templates/session/deviceauth.html:33 +#: uffd/templates/session/deviceauth.html:33 msgid "" "Start logging into a service on the other device and chose \"Device " "Login\" on the login page. Enter the displayed initiation code in the box" @@ -1488,16 +1319,16 @@ msgstr "" "\"Gerätelogin\" auf der Anmeldeseite aus. Gib den angezeigten Startcode " "oben ein." -#: uffd/session/templates/session/deviceauth.html:43 +#: uffd/templates/session/deviceauth.html:43 #, python-format msgid "Authorize the login for service <b>%(service_name)s</b>?" msgstr "Anmeldung an Dienst <b>%(service_name)s</b> erlauben?" -#: uffd/session/templates/session/deviceauth.html:46 +#: uffd/templates/session/deviceauth.html:46 msgid "Authorize Login" msgstr "Anmeldung erlauben" -#: uffd/session/templates/session/deviceauth.html:53 +#: uffd/templates/session/deviceauth.html:53 msgid "" "Enter the confirmation code on the other device and complete the login. " "Click <em>Finish</em> afterwards." @@ -1505,16 +1336,11 @@ msgstr "" "Gib den Bestätigungscode auf dem anderen Gerät ein und schließe die " "Anmeldung ab. Clicke danach auf <em>Abschließen</em>." -#: uffd/session/templates/session/deviceauth.html:56 +#: uffd/templates/session/deviceauth.html:56 msgid "Finish" msgstr "Beenden" -#: uffd/session/templates/session/devicelogin.html:6 -#: uffd/templates/base.html:93 -msgid "Device Login" -msgstr "Gerätelogin" - -#: uffd/session/templates/session/devicelogin.html:9 +#: uffd/templates/session/devicelogin.html:9 msgid "" "Use a login session on another device (e.g. your laptop) to log into a " "service without entering your password." @@ -1522,7 +1348,7 @@ msgstr "" "Nutze eine Login-Sitzung auf einem anderen Gerät (z.B. deinem Laptop) um " "dich bei einem Dienst anzumelden." -#: uffd/session/templates/session/devicelogin.html:22 +#: uffd/templates/session/devicelogin.html:22 #, python-format msgid "" "Open <code><a href=\"%(deviceauth_url)s\">%(deviceauth_url)s</a></code> " @@ -1533,97 +1359,47 @@ msgstr "" "auf dem anderen Gerät und gib dort den obenstehenden Startcode ein. Geben" " anschließend den Bestätigungscode hier ein." -#: uffd/session/templates/session/login.html:23 +#: uffd/templates/session/login.html:23 msgid "- or -" msgstr "- oder -" -#: uffd/session/templates/session/login.html:25 +#: uffd/templates/session/login.html:25 msgid "Login with another device" msgstr "Über anderes Gerät anmelden" -#: uffd/session/templates/session/login.html:30 +#: uffd/templates/session/login.html:30 msgid "Register" msgstr "Registrieren" -#: uffd/session/templates/session/login.html:32 +#: uffd/templates/session/login.html:32 msgid "Forgot Password?" msgstr "Passwort vergessen?" -#: uffd/signup/models.py:77 uffd/signup/models.py:102 -msgid "Invalid signup request" -msgstr "Ungültiger Account-Registrierungs-Link" - -#: uffd/signup/models.py:79 -msgid "Login name is invalid" -msgstr "Anmeldename ist ungültig" - -#: uffd/signup/models.py:81 -msgid "Display name is invalid" -msgstr "Anzeigename ist ungültig" - -#: uffd/signup/models.py:83 -msgid "Mail address is invalid" -msgstr "E-Mail-Adresse nicht valide" - -#: uffd/signup/models.py:87 uffd/signup/models.py:106 -msgid "A user with this login name already exists" -msgstr "Ein Account mit diesem Anmeldenamen existiert bereits" - -#: uffd/signup/models.py:88 -msgid "Valid" -msgstr "Gültig" - -#: uffd/signup/models.py:104 uffd/signup/views.py:106 -msgid "Wrong password" -msgstr "Falsches Passwort" - -#: uffd/signup/views.py:23 -msgid "Signup not enabled" -msgstr "Account-Registrierung ist deaktiviert" - -#: uffd/signup/views.py:86 uffd/signup/views.py:94 -msgid "Invalid signup link" -msgstr "Ungültiger Account-Registrierungs-Link" - -#: uffd/signup/views.py:99 -#, python-format -msgid "Too many failed attempts! Please wait %(delay)s." -msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s." - -#: uffd/signup/views.py:114 -msgid "Your account was successfully created" -msgstr "Account erfolgreich erstellt" - -#: uffd/signup/templates/signup/confirm.html:6 +#: uffd/templates/signup/confirm.html:6 msgid "Complete Registration" msgstr "Account-Registrierung abschließen" -#: uffd/signup/templates/signup/confirm.html:9 +#: uffd/templates/signup/confirm.html:9 msgid "Please enter your password to complete the account registration" msgstr "Bitte gib dein Passwort ein, um die Account-Registrierung abzuschließen" -#: uffd/signup/templates/signup/confirm.html:13 +#: uffd/templates/signup/confirm.html:13 msgid "Complete Account Registration" msgstr "Account-Registrierung abschließen" -#: uffd/signup/templates/signup/start.html:13 +#: uffd/templates/signup/start.html:13 msgid "Check" msgstr "Überprüfen" -#: uffd/signup/templates/signup/start.html:18 -#: uffd/user/templates/group/show.html:29 -msgid "" -"At least one and at most 32 lower-case characters, digits, dashes (\"-\")" -" or underscores (\"_\"). <b>Cannot be changed later!</b>" -msgstr "" -"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche " -"(\"_\"). <b>Kann später nicht geändert werden!</b>" - -#: uffd/signup/templates/signup/start.html:25 +#: uffd/templates/signup/start.html:25 msgid "At least one and at most 128 characters, no other special requirements." msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen." -#: uffd/signup/templates/signup/start.html:32 +#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:86 +msgid "E-Mail Address" +msgstr "E-Mail-Adresse" + +#: uffd/templates/signup/start.html:32 msgid "" "We will send a confirmation mail to this address that you need to " "complete the registration." @@ -1631,27 +1407,27 @@ msgstr "" "Wir werden eine Bestätigungsmail an diese Adresse senden. Du benötigst " "sie, um die Account-Registrierung abzuschließen." -#: uffd/signup/templates/signup/start.html:47 +#: uffd/templates/signup/start.html:47 msgid "Create Account" msgstr "Account registrieren" -#: uffd/signup/templates/signup/start.html:74 +#: uffd/templates/signup/start.html:74 msgid "The name is already taken" msgstr "Dieser Name wird bereits verwendet" -#: uffd/signup/templates/signup/start.html:77 +#: uffd/templates/signup/start.html:77 msgid "Too many requests! Please wait a bit before trying again!" msgstr "Zu viele Anfragen! Bitte warte etwas, bevor du es erneut versuchst!" -#: uffd/signup/templates/signup/start.html:80 +#: uffd/templates/signup/start.html:80 msgid "The name is invalid" msgstr "Name ungültig" -#: uffd/signup/templates/signup/submitted.html:5 +#: uffd/templates/signup/submitted.html:5 msgid "Confirm your E-Mail Address" msgstr "E-Mail-Adresse bestätigen" -#: uffd/signup/templates/signup/submitted.html:7 +#: uffd/templates/signup/submitted.html:7 #, python-format msgid "" "We sent a confirmation mail to <b>%(signup_mail)s</b>. You need to " @@ -1662,7 +1438,7 @@ msgstr "" "deine E-Mail-Adresse innerhalb von 48 Stunden bestätigen, um die Account-" "Registrierung abzuschließen." -#: uffd/signup/templates/signup/submitted.html:8 +#: uffd/templates/signup/submitted.html:8 msgid "" "If you mistyped your mail address or don't receive the confirmation mail " "for another reason, retry the registration procedure from the beginning." @@ -1671,120 +1447,23 @@ msgstr "" " Gründen keine Bestätigungsmail erhalten hast, kannst du den Prozess " "einfach von Vorne beginnen." -#: uffd/templates/403.html:10 -msgid "Access Denied" -msgstr "Zugriff verweigert" - -#: uffd/templates/403.html:17 -msgid "You don't have the permission to access this page." -msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen." - -#: uffd/templates/base.html:84 -msgid "Change" -msgstr "Ändern" - -#: uffd/templates/base.html:142 -msgid "About uffd" -msgstr "Über uffd" - -#: uffd/user/models.py:32 -#, python-format -msgid "" -"At least %(minlen)d and at most %(maxlen)d characters. Only letters, " -"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. " -"Please use a password manager." -msgstr "" -"%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen " -"und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte " -"verwende einen Passwort-Manager." - -#: uffd/user/views_group.py:23 -msgid "Groups" -msgstr "Gruppen" - -#: uffd/user/views_group.py:42 -msgid "Invalid name" -msgstr "Ungültiger Name" - -#: uffd/user/views_group.py:53 -msgid "Group with this name or id already exists" -msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits" - -#: uffd/user/views_group.py:58 -msgid "Group created" -msgstr "Gruppe erstellt" - -#: uffd/user/views_group.py:60 -msgid "Group updated" -msgstr "Gruppe aktualisiert" - -#: uffd/user/views_group.py:69 -msgid "Deleted group" -msgstr "Gruppe gelöscht" - -#: uffd/user/views_user.py:31 -msgid "Users" -msgstr "Accounts" - -#: uffd/user/views_user.py:51 -msgid "Login name does not meet requirements" -msgstr "Anmeldename entspricht nicht den Anforderungen" - -#: uffd/user/views_user.py:56 -msgid "Mail is invalid" -msgstr "E-Mail-Adresse nicht valide" - -#: uffd/user/views_user.py:60 -msgid "Display name does not meet requirements" -msgstr "Anzeigename entspricht nicht den Anforderungen" - -#: uffd/user/views_user.py:65 -msgid "Password is invalid" -msgstr "Passwort ist ungültig" - -#: uffd/user/views_user.py:78 -msgid "Service user created" -msgstr "Service-Account erstellt" - -#: uffd/user/views_user.py:81 -msgid "User created. We sent the user a password reset link by mail" -msgstr "" -"Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde " -"versendet." - -#: uffd/user/views_user.py:83 -msgid "User updated" -msgstr "Account aktualisiert" - -#: uffd/user/views_user.py:93 -msgid "Deleted user" -msgstr "Account gelöscht" - -#: uffd/user/templates/group/list.html:14 -msgid "GID" -msgstr "GID" - -#: uffd/user/templates/group/show.html:18 -msgid "Group ID" -msgstr "Gruppen ID" - -#: uffd/user/templates/user/list.html:11 +#: uffd/templates/user/list.html:11 msgid "CSV import" msgstr "CSV-Import" -#: uffd/user/templates/user/list.html:17 +#: uffd/templates/user/list.html:17 msgid "UID" msgstr "UID" -#: uffd/user/templates/user/list.html:34 uffd/user/templates/user/show.html:29 +#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:44 msgid "service" msgstr "service" -#: uffd/user/templates/user/list.html:57 +#: uffd/templates/user/list.html:57 msgid "Import a csv formated list of users" msgstr "Importiere eine als CSV formatierte Liste von Accounts" -#: uffd/user/templates/user/list.html:64 +#: uffd/templates/user/list.html:64 msgid "" "The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither " "setting the display name nor setting passwords is supported (yet). " @@ -1794,27 +1473,31 @@ msgstr "" "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. " "Beispiel:" -#: uffd/user/templates/user/list.html:75 uffd/user/templates/user/show.html:57 +#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:72 msgid "Ignore login name blocklist" msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren" -#: uffd/user/templates/user/list.html:80 +#: uffd/templates/user/list.html:80 msgid "Import" msgstr "Importieren" -#: uffd/user/templates/user/show.html:27 +#: uffd/templates/user/show.html:6 +msgid "New address" +msgstr "Neue Adresse" + +#: uffd/templates/user/show.html:42 msgid "User ID" msgstr "Account ID" -#: uffd/user/templates/user/show.html:35 +#: uffd/templates/user/show.html:50 msgid "will be choosen" msgstr "wird automatisch bestimmt" -#: uffd/user/templates/user/show.html:42 +#: uffd/templates/user/show.html:57 msgid "Service User" msgstr "Service-Account" -#: uffd/user/templates/user/show.html:50 +#: uffd/templates/user/show.html:65 msgid "" "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are " "allowed. At most 32, at least 2 characters. There is a word blocklist. " @@ -1824,7 +1507,7 @@ msgstr "" "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht " "erlaubter Namen. Muss einmalig sein." -#: uffd/user/templates/user/show.html:65 +#: uffd/templates/user/show.html:80 msgid "" "If you leave this empty it will be set to the login name. At most 128, at" " least 2 characters. No character restrictions." @@ -1832,36 +1515,420 @@ msgstr "" "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, " "mindestens 2 Zeichen. Keine Zeichenbeschränkung." -#: uffd/user/templates/user/show.html:69 -msgid "Mail" -msgstr "E-Mail-Adresse" +#: uffd/templates/user/show.html:89 +msgid "" +"Make sure the address is correct! Services might use e-mail addresses as " +"account identifiers and rely on them being unique and verified." +msgstr "" +"Stelle sicher, dass die Adresse korrekt ist! Manche Dienste verwenden die" +" E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, " +"dass diese verifiziert und einzigartig sind." + +#: uffd/templates/user/show.html:98 +msgid "Address" +msgstr "Adresse" + +#: uffd/templates/user/show.html:99 +msgid "Verified" +msgstr "Verifiziert" -#: uffd/user/templates/user/show.html:72 +#: uffd/templates/user/show.html:125 msgid "" -"Check that the address is unique. A user can take over another account if" -" both have the same mail address set." +"Make sure that addresses you add are correct! Services might use e-mail " +"addresses as account identifiers and rely on them being unique and " +"verified." msgstr "" -"Überprüfe, ob die E-Mail-Adresse noch unbenutzt ist! Ein Account kann " -"einen anderen Account übernehmen, wenn beide die selbe E-Mail-Adresse " -"verwenden." +"Stelle sicher, dass Adressen, die du hinzufügst, korrekt sind! Manche " +"Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und " +"verlassen sich darauf, dass diese verifiziert und einzigartig sind." -#: uffd/user/templates/user/show.html:80 -msgid "mail to set it will be sent" +#: uffd/templates/user/show.html:129 +msgid "Primary E-Mail Address" +msgstr "Primäre E-Mail-Adresse" + +#: uffd/templates/user/show.html:137 +msgid "Recovery E-Mail Address" +msgstr "Wiederherstellungs-E-Mail-Adresse" + +#: uffd/templates/user/show.html:152 +msgid "E-Mail to set it will be sent" msgstr "Mail zum Setzen wird versendet" -#: uffd/user/templates/user/show.html:91 +#: uffd/templates/user/show.html:163 msgid "Status:" msgstr "Status:" -#: uffd/user/templates/user/show.html:91 +#: uffd/templates/user/show.html:163 msgid "Enabled" msgstr "Aktiv" -#: uffd/user/templates/user/show.html:94 +#: uffd/templates/user/show.html:166 msgid "Reset 2FA" msgstr "2FA zurücksetzen" -#: uffd/user/templates/user/show.html:134 +#: uffd/templates/user/show.html:206 msgid "Resulting groups (only updated after save)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" +#: uffd/views/group.py:22 +msgid "Groups" +msgstr "Gruppen" + +#: uffd/views/group.py:41 +msgid "Invalid name" +msgstr "Ungültiger Name" + +#: uffd/views/group.py:52 +msgid "Group with this name or id already exists" +msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits" + +#: uffd/views/group.py:57 +msgid "Group created" +msgstr "Gruppe erstellt" + +#: uffd/views/group.py:59 +msgid "Group updated" +msgstr "Gruppe aktualisiert" + +#: uffd/views/group.py:68 +msgid "Deleted group" +msgstr "Gruppe gelöscht" + +#: uffd/views/invite.py:43 +msgid "Invites" +msgstr "Einladungslinks" + +#: uffd/views/invite.py:75 +msgid "The \"Expires After\" date is too far in the future" +msgstr "Das Ablaufdatum liegt zu weit in der Zukunft" + +#: uffd/views/invite.py:78 +msgid "You are not allowed to create invite links with these permissions" +msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen" + +#: uffd/views/invite.py:81 +msgid "Invite link must either allow signup or grant at least one role" +msgstr "" +"Einladungslink muss entweder Account-Registrierung erlauben oder Rollen " +"vergeben" + +#: uffd/views/invite.py:111 uffd/views/invite.py:146 +msgid "Invalid invite link" +msgstr "Ungültiger Einladungslink" + +#: uffd/views/invite.py:129 +msgid "Roles successfully updated" +msgstr "Rollen erfolgreich geändert" + +#: uffd/views/invite.py:149 +msgid "Invite link does not allow signup" +msgstr "Einladungslink erlaubt keine Account-Registrierung" + +#: uffd/views/invite.py:175 uffd/views/selfservice.py:44 +#: uffd/views/signup.py:47 +msgid "Passwords do not match" +msgstr "Die Passwörter stimmen nicht überein" + +#: uffd/views/invite.py:181 uffd/views/signup.py:53 +#, python-format +msgid "Too many signup requests with this mail address! Please wait %(delay)s." +msgstr "" +"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte " +"%(delay)s." + +#: uffd/views/invite.py:184 uffd/views/signup.py:56 +#, python-format +msgid "Too many requests! Please wait %(delay)s." +msgstr "Zu viele Anfragen! Bitte warte %(delay)s." + +#: uffd/views/invite.py:199 uffd/views/signup.py:74 +msgid "Could not send mail" +msgstr "Mailversand fehlgeschlagen" + +#: uffd/views/mail.py:21 +msgid "Forwardings" +msgstr "Weiterleitungen" + +#: uffd/views/mail.py:46 +#, python-format +msgid "Invalid receive address: %(mail_address)s" +msgstr "Ungültige Empfangsadresse: %(mail_address)s" + +#: uffd/views/mail.py:50 +msgid "Mail mapping updated." +msgstr "Mailweiterleitung geändert." + +#: uffd/views/mail.py:59 +msgid "Deleted mail mapping." +msgstr "Mailweiterleitung gelöscht." + +#: uffd/views/mfa.py:49 +msgid "Two-factor authentication was reset" +msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt" + +#: uffd/views/mfa.py:78 +msgid "Generate recovery codes first!" +msgstr "Generiere zuerst die Wiederherstellungscodes!" + +#: uffd/views/mfa.py:86 +msgid "Code is invalid" +msgstr "Wiederherstellungscode ist ungültig" + +#: uffd/views/mfa.py:105 +#, python-format +msgid "" +"2FA WebAuthn support disabled because import of the fido2 module failed " +"(%s)" +msgstr "" +"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen " +"werden konnte (%s)" + +#: uffd/views/mfa.py:214 +#, python-format +msgid "We received too many invalid attempts! Please wait at least %s." +msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s." + +#: uffd/views/mfa.py:228 +msgid "You have exhausted your recovery codes. Please generate new ones now!" +msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!" + +#: uffd/views/mfa.py:231 +msgid "" +"You only have a few recovery codes remaining. Make sure to generate new " +"ones before they run out." +msgstr "" +"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere " +"diese erneut bevor keine mehr übrig sind." + +#: uffd/views/mfa.py:235 +msgid "Two-factor authentication failed" +msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen" + +#: uffd/views/oauth2.py:167 uffd/views/selfservice.py:66 +#: uffd/views/session.py:72 +#, python-format +msgid "" +"We received too many requests from your ip address/network! Please wait " +"at least %(delay)s." +msgstr "" +"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem " +"Netzwerk empfangen! Bitte warte mindestens %(delay)s." + +#: uffd/views/oauth2.py:175 +msgid "Device login is currently not available. Try again later!" +msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!" + +#: uffd/views/oauth2.py:188 +msgid "Device login failed" +msgstr "Gerätelogin fehlgeschlagen" + +#: uffd/views/oauth2.py:194 +msgid "You need to login to access this service" +msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" + +#: uffd/views/oauth2.py:201 +#, python-format +msgid "" +"You don't have the permission to access the service " +"<b>%(service_name)s</b>." +msgstr "" +"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> " +"zuzugreifen." + +#: uffd/views/role.py:68 +msgid "Locked roles cannot be deleted" +msgstr "Gesperrte Rollen können nicht gelöscht werden" + +#: uffd/views/rolemod.py:22 +msgid "Moderation" +msgstr "Moderation" + +#: uffd/views/rolemod.py:42 +msgid "Description too long" +msgstr "Beschreibung zu lang" + +#: uffd/views/rolemod.py:59 +msgid "Member removed" +msgstr "Mitglied entfernt" + +#: uffd/views/selfservice.py:22 +msgid "Selfservice" +msgstr "Selfservice" + +#: uffd/views/selfservice.py:33 +msgid "Display name changed." +msgstr "Anzeigename geändert." + +#: uffd/views/selfservice.py:35 +msgid "Display name is not valid." +msgstr "Anzeigename ist nicht valide." + +#: uffd/views/selfservice.py:47 +msgid "Password changed" +msgstr "Passwort geändert" + +#: uffd/views/selfservice.py:64 +#, python-format +msgid "" +"We received too many password reset requests for this user! Please wait " +"at least %(delay)s." +msgstr "" +"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! " +"Bitte warte mindestens %(delay)s." + +#: uffd/views/selfservice.py:70 +msgid "" +"We sent a mail to this user's mail address if you entered the correct " +"mail and login name combination" +msgstr "" +"Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an " +"die Adresse gesendet." + +#: uffd/views/selfservice.py:87 uffd/views/selfservice.py:138 +#: uffd/views/selfservice.py:143 +msgid "Link invalid or expired" +msgstr "Link ist ungültig oder abgelaufen" + +#: uffd/views/selfservice.py:92 +msgid "You need to set a password, please try again." +msgstr "Password fehlt, bitte versuche es erneut." + +#: uffd/views/selfservice.py:95 +msgid "Passwords do not match, please try again." +msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut" + +#: uffd/views/selfservice.py:100 +msgid "Password ist not valid, please try again." +msgstr "Ungültiges Passwort, bitte versuche es erneut" + +#: uffd/views/selfservice.py:104 +msgid "New password set" +msgstr "Passwort geändert" + +#: uffd/views/selfservice.py:117 +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 +#, python-format +msgid "E-Mail to \"%(mail_address)s\" could not be sent!" +msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" + +#: uffd/views/selfservice.py:126 uffd/views/selfservice.py:160 +msgid "We sent you an email, please verify your mail address." +msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse." + +#: uffd/views/selfservice.py:141 +msgid "" +"This link was generated for another user. Login as the correct user to " +"continue." +msgstr "" +"Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem " +"richtigen Account an um Fortzufahren." + +#: uffd/views/selfservice.py:148 +msgid "E-Mail address verified" +msgstr "E-Mail-Adresse verifiziert" + +#: uffd/views/selfservice.py:173 +msgid "E-Mail address deleted" +msgstr "E-Mail-Adresse gelöscht" + +#: uffd/views/selfservice.py:192 +msgid "E-Mail preferences updated" +msgstr "E-Mail-Einstellungen geändert" + +#: uffd/views/selfservice.py:203 +#, python-format +msgid "You left role %(role_name)s" +msgstr "Rolle %(role_name)s verlassen" + +#: uffd/views/service.py:32 +msgid "Services" +msgstr "Dienste" + +#: uffd/views/session.py:70 +#, python-format +msgid "" +"We received too many invalid login attempts for this user! Please wait at" +" least %(delay)s." +msgstr "" +"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account " +"erhalten! Bitte warte mindestens %(delay)s." + +#: uffd/views/session.py:78 +msgid "Login name or password is wrong" +msgstr "Der Anmeldename oder das Passwort ist falsch" + +#: uffd/views/session.py:84 +msgid "You do not have access to this service" +msgstr "Du hast keinen Zugriff auf diesen Service" + +#: uffd/views/session.py:96 uffd/views/session.py:107 +msgid "You need to login first" +msgstr "Du musst dich erst anmelden" + +#: uffd/views/session.py:128 uffd/views/session.py:138 +msgid "Initiation code is no longer valid" +msgstr "Startcode ist nicht mehr gültig" + +#: uffd/views/session.py:142 +msgid "Invalid confirmation code" +msgstr "Ungültiger Bestätigungscode" + +#: uffd/views/session.py:154 uffd/views/session.py:165 +msgid "Invalid initiation code" +msgstr "Ungültiger Startcode" + +#: uffd/views/signup.py:21 +msgid "Signup not enabled" +msgstr "Account-Registrierung ist deaktiviert" + +#: uffd/views/signup.py:84 uffd/views/signup.py:92 +msgid "Invalid signup link" +msgstr "Ungültiger Account-Registrierungs-Link" + +#: uffd/views/signup.py:97 +#, python-format +msgid "Too many failed attempts! Please wait %(delay)s." +msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s." + +#: uffd/views/signup.py:112 +msgid "Your account was successfully created" +msgstr "Account erfolgreich erstellt" + +#: uffd/views/user.py:30 +msgid "Users" +msgstr "Accounts" + +#: uffd/views/user.py:51 +msgid "Login name does not meet requirements" +msgstr "Anmeldename entspricht nicht den Anforderungen" + +#: uffd/views/user.py:96 +msgid "Display name does not meet requirements" +msgstr "Anzeigename entspricht nicht den Anforderungen" + +#: uffd/views/user.py:102 +msgid "Password is invalid" +msgstr "Passwort ist ungültig" + +#: uffd/views/user.py:118 +msgid "Service user created" +msgstr "Service-Account erstellt" + +#: uffd/views/user.py:121 +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 +msgid "User updated" +msgstr "Account aktualisiert" + +#: uffd/views/user.py:133 +msgid "Deleted user" +msgstr "Account gelöscht" + diff --git a/uffd/views/api.py b/uffd/views/api.py index 0bfa71e7..cdab573d 100644 --- a/uffd/views/api.py +++ b/uffd/views/api.py @@ -97,6 +97,7 @@ def getusers(): if key is None or key == 'group': # pylint: disable=no-member query = query.options(db.joinedload(ServiceUser.user).selectinload(User.groups)) + query = query.options(db.joinedload(ServiceUser.user).joinedload(User.primary_email)) return jsonify([generate_user_dict(user) for user in query]) @bp.route('/checkpassword', methods=['POST']) diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py index fee71b8d..95883536 100644 --- a/uffd/views/selfservice.py +++ b/uffd/views/selfservice.py @@ -2,12 +2,13 @@ import secrets from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort from flask_babel import gettext as _, lazy_gettext +from sqlalchemy.exc import IntegrityError from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.sendmail import sendmail from uffd.database import db -from uffd.models import User, PasswordToken, MailToken, Role, host_ratelimit, Ratelimit, format_delay +from uffd.models import User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay from .session import login_required bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/') @@ -32,9 +33,6 @@ def update_profile(): flash(_('Display name changed.')) else: flash(_('Display name is not valid.')) - if request.values['mail'] != request.user.mail: - send_mail_verification(request.user, request.values['mail']) - flash(_('We sent you an email, please verify your mail address.')) db.session.commit() return redirect(url_for('selfservice.index')) @@ -71,7 +69,13 @@ def forgot_password(): host_ratelimit.log() flash(_("We sent a mail to this user's mail address if you entered the correct mail and login name combination")) user = User.query.filter_by(loginname=loginname).one_or_none() - if user and user.mail == mail and user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']): + if not user: + return redirect(url_for('session.login')) + matches = any(map(lambda email: secrets.compare_digest(email.address, mail), user.verified_emails)) + if not matches: + return redirect(url_for('session.login')) + recovery_email = user.recovery_email or user.primary_email + if recovery_email.address == mail and user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']): send_passwordreset(user) return redirect(url_for('session.login')) @@ -100,20 +104,92 @@ def token_password(token_id, token): flash(_('New password set')) return redirect(url_for('session.login')) -@bp.route("/token/mail_verification/<int:token_id>/<token>") +@bp.route("/email/new", methods=['POST']) @login_required(selfservice_acl_check) -def token_mail(token_id, token): - dbtoken = MailToken.query.get(token_id) - if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \ - dbtoken.expired: +def add_email(): + email = UserEmail(user=request.user) + if not email.set_address(request.form['address']): + flash(_('E-Mail address is invalid')) + return redirect(url_for('selfservice.index')) + try: + db.session.flush() + except IntegrityError: + flash(_('E-Mail address already exists')) + return redirect(url_for('selfservice.index')) + + secret = email.start_verification() + db.session.add(email) + db.session.commit() + if not sendmail(email.address, 'Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret): + flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address)) + else: + flash(_('We sent you an email, please verify your mail address.')) + return redirect(url_for('selfservice.index')) + +@bp.route("/email/<int:email_id>/verify/<secret>") +@bp.route("/token/mail_verification/<int:legacy_id>/<secret>") +@login_required(selfservice_acl_check) +def verify_email(secret, email_id=None, legacy_id=None): + if email_id is not None: + email = UserEmail.query.get(email_id) + else: + email = UserEmail.query.filter_by(verification_legacy_id=legacy_id).one() + if not email or email.verification_expired: flash(_('Link invalid or expired')) return redirect(url_for('selfservice.index')) - if dbtoken.user != request.user: + if email.user != request.user: abort(403, description=_('This link was generated for another user. Login as the correct user to continue.')) - dbtoken.user.set_mail(dbtoken.newmail) - db.session.delete(dbtoken) + if not email.finish_verification(secret): + flash(_('Link invalid or expired')) + return redirect(url_for('selfservice.index')) + if legacy_id is not None: + request.user.primary_email = email db.session.commit() - flash(_('New mail set')) + flash(_('E-Mail address verified')) + return redirect(url_for('selfservice.index')) + +@bp.route("/email/<int:email_id>/retry") +@login_required(selfservice_acl_check) +def retry_email_verification(email_id): + email = UserEmail.query.filter_by(id=email_id, user=request.user, verified=False).first_or_404() + secret = email.start_verification() + db.session.commit() + if not sendmail(email.address, 'E-Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret): + flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address)) + else: + flash(_('We sent you an email, please verify your mail address.')) + return redirect(url_for('selfservice.index')) + +@bp.route("/email/<int:email_id>/delete", methods=['POST', 'GET']) +@login_required(selfservice_acl_check) +def delete_email(email_id): + email = UserEmail.query.filter_by(id=email_id, user=request.user).first_or_404() + try: + db.session.delete(email) + db.session.commit() + except IntegrityError: + flash(_('Cannot delete primary e-mail address')) + return redirect(url_for('selfservice.index')) + flash(_('E-Mail address deleted')) + return redirect(url_for('selfservice.index')) + +@bp.route("/email/preferences", methods=['POST']) +@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 + 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 + db.session.commit() + flash(_('E-Mail preferences updated')) return redirect(url_for('selfservice.index')) @bp.route("/leaverole/<int:roleid>", methods=(['POST'])) @@ -127,15 +203,6 @@ def leave_role(roleid): flash(_('You left role %(role_name)s', role_name=role.name)) return redirect(url_for('selfservice.index')) -def send_mail_verification(user, newmail): - MailToken.query.filter(MailToken.user == user).delete() - token = MailToken(user=user, newmail=newmail) - db.session.add(token) - db.session.commit() - - if not sendmail(newmail, 'Mail verification', 'selfservice/mailverification.mail.txt', user=user, token=token): - flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=newmail)) - def send_passwordreset(user, new=False): PasswordToken.query.filter(PasswordToken.user == user).delete() token = PasswordToken(user=user) @@ -149,5 +216,6 @@ def send_passwordreset(user, new=False): template = 'selfservice/passwordreset.mail.txt' subject = 'Password reset' - if not sendmail(user.mail, subject, template, user=user, token=token): - flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=user.mail)) + email = user.recovery_email or user.primary_email + if not sendmail(email.address, subject, template, user=user, token=token): + flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address)) diff --git a/uffd/views/user.py b/uffd/views/user.py index 8e2ab638..3067a4cb 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 +from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort from flask_babel import gettext as _, lazy_gettext from sqlalchemy.exc import IntegrityError @@ -9,7 +9,7 @@ from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.remailer import remailer from uffd.database import db -from uffd.models import User, Role +from uffd.models import User, UserEmail, Role from .selfservice import send_passwordreset from .session import login_required @@ -41,6 +41,7 @@ def show(id=None): @bp.route("/new", methods=['POST']) @csrf_protect(blueprint=bp) def update(id=None): + # pylint: disable=too-many-branches,too-many-statements if id is None: user = User() ignore_blocklist = request.form.get('ignore-loginname-blocklist', False) @@ -49,21 +50,60 @@ def update(id=None): if not user.set_loginname(request.form['loginname'], ignore_blocklist=ignore_blocklist): flash(_('Login name does not meet requirements')) return redirect(url_for('user.show')) + if not user.set_primary_email_address(request.form['email']): + flash(_('E-Mail address is invalid')) + return redirect(url_for('user.show')) else: user = User.query.get_or_404(id) - if user.mail != request.form['mail'] and not user.set_mail(request.form['mail']): - flash(_('Mail is invalid')) - return redirect(url_for('user.show', id=id)) + + for email in user.all_emails: + if f'email-{email.id}-present' in request.form: + email.verified = email.verified or (request.form.get(f'email-{email.id}-verified') == '1') + + for key, value in request.form.items(): + parts = key.split('-') + if not parts[0] == 'newemail' or not parts[2] == 'address' or not value: + continue + tmp_id = parts[1] + email = UserEmail( + user=user, + verified=(request.form.get(f'newemail-{tmp_id}-verified') == '1'), + ) + if not email.set_address(value): + flash(_('E-Mail address is invalid')) + return redirect(url_for('user.show', id=id)) + 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 + 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 + + for email in user.all_emails: + if request.form.get(f'email-{email.id}-delete') == '1': + db.session.delete(email) + new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname'] if user.displayname != new_displayname and not user.set_displayname(new_displayname): flash(_('Display name does not meet requirements')) return redirect(url_for('user.show', id=id)) + new_password = request.form.get('password') if id is not None and new_password: if not user.set_password(new_password): flash(_('Password is invalid')) return redirect(url_for('user.show', id=id)) + db.session.add(user) + user.roles.clear() for role in Role.query.all(): if not user.is_service_user and role.is_default: @@ -71,13 +111,14 @@ def update(id=None): if request.values.get('role-{}'.format(role.id), False): user.roles.append(role) user.update_groups() + db.session.commit() if id is None: if user.is_service_user: flash(_('Service user created')) else: send_passwordreset(user, new=True) - flash(_('User created. We sent the user a password reset link by mail')) + flash(_('User created. We sent the user a password reset link by e-mail')) else: flash(_('User updated')) return redirect(url_for('user.show', id=user.id)) @@ -114,8 +155,8 @@ def csvimport(): if not newuser.set_loginname(row[0], ignore_blocklist=ignore_blocklist) or not newuser.set_displayname(row[0]): flash("invalid login name, skipped : {}".format(row)) continue - if not newuser.set_mail(row[1]): - flash("invalid mail address, skipped : {}".format(row)) + if not newuser.set_primary_email_address(row[1]): + flash("invalid e-mail address, skipped : {}".format(row)) continue db.session.add(newuser) for role in roles: -- GitLab