diff --git a/check_migrations.py b/check_migrations.py index 1a9773ea68c6e6939525b5845bd1b2ce739f6a5e..eb8d8bc046a9649377e786b125dd4434cab47ffa 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 7734da840a7cf4d6db8969d9c64d5a90b3ced030..9200fc898aca7a87cf11fe35c1c569da28718afe 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 93dea192ec59f0fe15236cda36106f0e6ebe5bd9..d9bc44dbac1263096aea8036a6a22e428eb45fa2 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 990e2c13238fe54da45c71af8fedf449957da5cb..1dea40742f5d906a2de3bc0728f1626d0fb396da 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 c02b862c7c8e4525b5a77d55c48c84afde660f2a..3283d2734ba025f6db168a29363217ed5cb5c985 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 760b7a449d9a30db0786201683a003f30584d7d8..b31e579e7584cd5cb38213174bc13f1c96a07955 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 643b1b45bc91c8cb62fccdd061bf13929e30cb6e..0c6970231c91285c7a6b57edcd0c736841e36688 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 cfb599d5c84e6307cc0cb0910ee5921907c2c18a..9affe7f90dab378f1f4aa8c062730d4fe8e3c7b4 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 190234d068b5ce3b09325e6cf26d034a1d8ca407..b023510cfefaf806b158b6449558c2d9f925d56c 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 c4017b22f14f93392775406379571be52b6554aa..3b98e64b9d8ac3dba8968338ccdd450ab3cc2ee2 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 5b82eec677016caf71c071ed4c63e811b3fe4484..cf3dd6e0be916548f3686c75be50073dca8bc767 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 f52906178252d2b10f66ca3c797d8b5c247b26aa..125458d80e25905a894bd42c7fdd1d97255a0c1c 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 0000000000000000000000000000000000000000..ca83799f275997ef41de980d802aece4aebf8a81 --- /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 cedc85e6e94282530962c6e8d29937cb1b50e6a6..52d9709b285d4fcacaa837bae570e4af7c79d729 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 5c4d5eb1079bb26ddf1ff05d5b501f58f8a1a644..be3f36a7a6a54da2e2e8e52bdccb2926db529696 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 6953ee71545878a7d248ec6ea4cd7c5b884bfa2a..131400f081a9f5ad9ea91f5c18cf7662477d9d70 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 c04dbb41731db2f65c13795137018b4f5a0859b6..e56f565e5120595b98c48cf57ab299e6f06884cd 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 f822e2324a6e3b885dc3257c09d80603d7698a08..374afef6fd1d881673bcdfc47de56adfab045972 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 f508be908b792299068e5c373e5196b38ec95ff1..4b705f2844166f815f9906885b94fd905ad25b43 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 f32b2e19356c7df3d784876bec5531c2a6fab695..39a36499b991047318c9d7984c592b122f98bc65 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 20f4f7a0652653a4759e626e62af17aed4b02494..8b814f5d2c5ae31cf929e8ed25a37dd91c6adbdf 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 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 87a74d145ac9ba72ddeb5a5ba99bf3a0eab78cb2..293df9db7c8090e4bfbef611e4136e680f61dcad 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 0bfa71e775348ecca23da62b08653133fcc24da3..cdab573db85fce5e919682008f01ebaddce9e662 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 fee71b8db93a74f3a8a00693dad9ff2c11596c68..9588353618151736c4abfbf3f75d16824b145617 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 8e2ab63866fb856c41e0ff6de3aad4259e035429..3067a4cb234a46fdff73c06b325a967060ccd93d 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: