From 17b993728251d88ad23321e7c72b5f23c903646a Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Fri, 4 Feb 2022 01:09:03 +0100
Subject: [PATCH] Constrain mail receive addresses and fix case-folding in API

Previously the getmails API endpoint did not match "receive_address" values
case-insensitivly like it did pre-v2. To solve this independent of database
collations, all existing mail receive addresses are converted to lower-case
and new/changed receive addresses are constraint to ASCII lower-case letters,
digits and symbols.
---
 tests/test_api.py                             |  25 ++++
 uffd/api/views.py                             |   2 +-
 uffd/mail/models.py                           |  18 +++
 uffd/mail/templates/mail/show.html            |   2 +-
 uffd/mail/views.py                            |   4 +
 ...5e3ac_lower_case_mail_receive_addresses.py |  29 ++++
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 31934 -> 32143 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  | 127 ++++++++++--------
 8 files changed, 146 insertions(+), 61 deletions(-)
 create mode 100644 uffd/migrations/versions/042879d5e3ac_lower_case_mail_receive_addresses.py

diff --git a/tests/test_api.py b/tests/test_api.py
index a77e302d..2cefc16e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -55,3 +55,28 @@ class TestAPIAuth(UffdTestCase):
 	def test_no_auth(self):
 		r = self.client.get(path=url_for('testendpoint1'), follow_redirects=True)
 		self.assertEqual(r.status_code, 401)
+
+class TestAPIViews(UffdTestCase):
+	def setUpApp(self):
+		self.app.config['API_CLIENTS_2'] = {
+			'test': {'client_secret': 'test', 'scopes': ['getmails']},
+		}
+
+	def test_lookup(self):
+		r = self.client.get(path=url_for('api.getmails', receive_address='test1@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, [{'name': 'test', 'receive_addresses': ['test1@example.com', 'test2@example.com'], 'destination_addresses': ['testuser@mail.example.com']}])
+		r = self.client.get(path=url_for('api.getmails', receive_address='test2@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, [{'name': 'test', 'receive_addresses': ['test1@example.com', 'test2@example.com'], 'destination_addresses': ['testuser@mail.example.com']}])
+
+	def test_lookup_notfound(self):
+		r = self.client.get(path=url_for('api.getmails', receive_address='test3@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, [])
+
+	def test_lookup_case_folding(self):
+		r = self.client.get(path=url_for('api.getmails', receive_address='Test1@example.com'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, [{'name': 'test', 'receive_addresses': ['test1@example.com', 'test2@example.com'], 'destination_addresses': ['testuser@mail.example.com']}])
+
diff --git a/uffd/api/views.py b/uffd/api/views.py
index cf793deb..b0869878 100644
--- a/uffd/api/views.py
+++ b/uffd/api/views.py
@@ -115,7 +115,7 @@ def getmails():
 	elif key == 'name' and len(values) == 1:
 		mails = Mail.query.filter_by(uid=values[0]).all()
 	elif key == 'receive_address' and len(values) == 1:
-		mails = Mail.query.filter(Mail.receivers.any(MailReceiveAddress.address==values[0])).all()
+		mails = Mail.query.filter(Mail.receivers.any(MailReceiveAddress.address==values[0].lower())).all()
 	elif key == 'destination_address' and len(values) == 1:
 		mails = Mail.query.filter(Mail.destinations.any(MailDestinationAddress.address==values[0])).all()
 	else:
diff --git a/uffd/mail/models.py b/uffd/mail/models.py
index 7734ec97..d9ce5497 100644
--- a/uffd/mail/models.py
+++ b/uffd/mail/models.py
@@ -1,3 +1,5 @@
+import re
+
 from sqlalchemy import Column, Integer, String, ForeignKey
 from sqlalchemy.orm import relationship
 from sqlalchemy.ext.associationproxy import association_proxy
@@ -5,6 +7,18 @@ from sqlalchemy.ext.associationproxy import association_proxy
 from uffd.database import db
 
 class Mail(db.Model):
+	# Aliases are looked up by receiver addresses with api.getmails. To emulate
+	# the pre-v2/LDAP behaviour, the lookup needs to be case-insensitive. To not
+	# rely on database-specific behaviour, we ensure that all receiver addresses
+	# are stored lower-case and convert incoming addresses in api.getmails to
+	# lower-case. Note that full emulation of LDAP behaviour would also require
+	# whitespace normalization. Instead we disallow spaces in receiver addresses.
+
+	# Match ASCII code points 33 (!) to 64 (@) and 91 ([) to 126 (~), i.e. any
+	# number of lower-case ASCII letters, digits, symbols
+	RECEIVER_REGEX = '[!-@[-~]*'
+	RECEIVER_REGEX_COMPILED = re.compile(RECEIVER_REGEX)
+
 	__tablename__ = 'mail'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
 	uid = Column(String(32), unique=True, nullable=False)
@@ -13,6 +27,10 @@ class Mail(db.Model):
 	_destinations = relationship('MailDestinationAddress', cascade='all, delete-orphan')
 	destinations = association_proxy('_destinations', 'address')
 
+	@property
+	def invalid_receivers(self):
+		return [addr for addr in self.receivers if not re.fullmatch(self.RECEIVER_REGEX_COMPILED, addr)]
+
 class MailReceiveAddress(db.Model):
 	__tablename__ = 'mail_receive_address'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
diff --git a/uffd/mail/templates/mail/show.html b/uffd/mail/templates/mail/show.html
index dea92a2f..44b0db5c 100644
--- a/uffd/mail/templates/mail/show.html
+++ b/uffd/mail/templates/mail/show.html
@@ -13,7 +13,7 @@
 		<label for="mail-receivers">{{_('Receiving addresses')}}</label>
 		<textarea rows="10" class="form-control" id="mail-receivers" name="mail-receivers">{{ mail.receivers|join('\n') }}</textarea>
 		<small class="form-text text-muted">
-			{{_('One address per line')}}
+			{{_('One address pattern (local+ext@domain, local@domain, local, @domain) per line. Only lower-case ASCII letters, digits and symbols.')}}
 		</small>
 	</div>
 	<div class="form-group col">
diff --git a/uffd/mail/views.py b/uffd/mail/views.py
index 8ee57c56..6f3f0c97 100644
--- a/uffd/mail/views.py
+++ b/uffd/mail/views.py
@@ -41,6 +41,10 @@ def update(uid=None):
 		mail = Mail(uid=request.form.get('mail-uid'))
 	mail.receivers = request.form.get('mail-receivers', '').splitlines()
 	mail.destinations = request.form.get('mail-destinations', '').splitlines()
+	if mail.invalid_receivers:
+		for addr in mail.invalid_receivers:
+			flash(_('Invalid receive address: %(mail_address)s', mail_address=addr))
+		return render_template('mail/show.html', mail=mail)
 	db.session.add(mail)
 	db.session.commit()
 	flash(_('Mail mapping updated.'))
diff --git a/uffd/migrations/versions/042879d5e3ac_lower_case_mail_receive_addresses.py b/uffd/migrations/versions/042879d5e3ac_lower_case_mail_receive_addresses.py
new file mode 100644
index 00000000..cfa2b813
--- /dev/null
+++ b/uffd/migrations/versions/042879d5e3ac_lower_case_mail_receive_addresses.py
@@ -0,0 +1,29 @@
+"""lower-case mail receive addresses
+
+Revision ID: 042879d5e3ac
+Revises: 878b25c4fae7
+Create Date: 2022-02-01 20:37:32.103288
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '042879d5e3ac'
+down_revision = '878b25c4fae7'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	mail_receive_address_table = sa.Table('mail_receive_address', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('mail_id', sa.Integer(), nullable=False),
+		sa.Column('address', sa.String(length=128), nullable=False),
+		sa.ForeignKeyConstraint(['mail_id'], ['mail.id'], name=op.f('fk_mail_receive_address_mail_id_mail'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_mail_receive_address'))
+	)
+	op.execute(mail_receive_address_table.update().values(address=sa.func.lower(mail_receive_address_table.c.address)))
+
+def downgrade():
+	pass
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 7c1c953373dc7e65bbf123a966f8c7efd0507552..7178b9b68894ad12809ce2d9fae534e339fbd7e1 100644
GIT binary patch
delta 5527
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
NaLvryyd+vm5dgT;^>qLM

delta 5285
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
GQi=d!6QZ5~

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 85347a75..217cba4f 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: 2021-10-04 00:22+0200\n"
+"POT-Creation-Date: 2022-02-03 16:51+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -62,36 +62,36 @@ msgstr ""
 "Einladungslink muss entweder Account-Registrierung erlauben oder Rollen "
 "vergeben"
 
-#: uffd/invite/views.py:122 uffd/invite/views.py:157
+#: uffd/invite/views.py:111 uffd/invite/views.py:146
 msgid "Invalid invite link"
 msgstr "Ungültiger Einladungslink"
 
-#: uffd/invite/views.py:140
+#: uffd/invite/views.py:129
 msgid "Roles successfully updated"
 msgstr "Rollen erfolgreich geändert"
 
-#: uffd/invite/views.py:160
+#: uffd/invite/views.py:149
 msgid "Invite link does not allow signup"
 msgstr "Einladungslink erlaubt keine Account-Registrierung"
 
-#: uffd/invite/views.py:186 uffd/selfservice/views.py:50
-#: uffd/signup/views.py:50
+#: uffd/invite/views.py:175 uffd/selfservice/views.py:50
+#: uffd/signup/views.py:49
 msgid "Passwords do not match"
 msgstr "Die Passwörter stimmen nicht überein"
 
-#: uffd/invite/views.py:191 uffd/signup/views.py:55
+#: uffd/invite/views.py:180 uffd/signup/views.py:54
 #, 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/invite/views.py:193 uffd/signup/views.py:57 uffd/signup/views.py:106
+#: uffd/invite/views.py:182 uffd/signup/views.py:56 uffd/signup/views.py:92
 #, python-format
 msgid "Too many requests! Please wait %(delay)s."
 msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
 
-#: uffd/invite/views.py:206 uffd/signup/views.py:69
+#: uffd/invite/views.py:195 uffd/signup/views.py:68
 msgid "Cound not send mail"
 msgstr "Mailversand fehlgeschlagen"
 
@@ -267,8 +267,8 @@ msgstr "Enthaltene Rollen"
 #: 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:46
-#: uffd/selfservice/templates/selfservice/self.html:101
+#: uffd/rolemod/templates/rolemod/show.html:44
+#: uffd/selfservice/templates/selfservice/self.html:97
 #: uffd/user/templates/group/list.html:15
 #: uffd/user/templates/group/show.html:26
 #: uffd/user/templates/user/show.html:106
@@ -279,10 +279,10 @@ 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:28
-#: uffd/selfservice/templates/selfservice/self.html:102
+#: 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:30
+#: uffd/user/templates/group/show.html:33
 #: uffd/user/templates/user/show.html:107
 #: uffd/user/templates/user/show.html:139
 msgid "Description"
@@ -295,7 +295,7 @@ 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:11
+#: uffd/rolemod/templates/rolemod/show.html:9
 #: uffd/session/templates/session/deviceauth.html:39
 #: uffd/session/templates/session/deviceauth.html:49
 #: uffd/session/templates/session/devicelogin.html:29
@@ -355,10 +355,15 @@ msgid "Forwardings"
 msgstr "Weiterleitungen"
 
 #: uffd/mail/views.py:46
+#, python-format
+msgid "Invalid receive address: %(mail_address)s"
+msgstr "Ungültige Empfangsadresse: %(mail_address)s"
+
+#: uffd/mail/views.py:50
 msgid "Mail mapping updated."
 msgstr "Mailweiterleitung geändert."
 
-#: uffd/mail/views.py:55
+#: uffd/mail/views.py:59
 msgid "Deleted mail mapping."
 msgstr "Mailweiterleitung gelöscht."
 
@@ -370,12 +375,20 @@ msgstr "Empfangsadressen"
 msgid "Destinations"
 msgstr "Zieladressen"
 
-#: uffd/mail/templates/mail/show.html:16 uffd/mail/templates/mail/show.html:23
+#: uffd/mail/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."
+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
 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:10
+#: uffd/rolemod/templates/rolemod/show.html:8
 #: uffd/user/templates/group/show.html:8 uffd/user/templates/user/show.html:7
 msgid "Save"
 msgstr "Speichern"
@@ -816,7 +829,7 @@ 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:117
+#: uffd/selfservice/templates/selfservice/self.html:112
 #: 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?"
@@ -855,8 +868,8 @@ msgid "Moderators"
 msgstr "Accounts mit Moderationsrechten"
 
 #: uffd/role/templates/role/show.html:71
-#: uffd/rolemod/templates/rolemod/show.html:18
-#: uffd/user/templates/group/show.html:37
+#: uffd/rolemod/templates/rolemod/show.html:16
+#: uffd/user/templates/group/show.html:40
 msgid "Members"
 msgstr "Mitglieder"
 
@@ -892,27 +905,27 @@ msgstr "Beschreibung zu lang"
 msgid "Member removed"
 msgstr "Mitglied entfernt"
 
-#: uffd/rolemod/templates/rolemod/show.html:8
+#: uffd/rolemod/templates/rolemod/show.html:7
 msgid "Invite Members"
 msgstr "Mitglieder einladen"
 
-#: uffd/rolemod/templates/rolemod/show.html:15
+#: uffd/rolemod/templates/rolemod/show.html:13
 msgid "Overview"
 msgstr "Übersicht"
 
-#: uffd/rolemod/templates/rolemod/show.html:24
+#: uffd/rolemod/templates/rolemod/show.html:22
 msgid "Role name"
 msgstr "Rollenname"
 
-#: uffd/rolemod/templates/rolemod/show.html:32
+#: uffd/rolemod/templates/rolemod/show.html:30
 msgid "Moderators:"
 msgstr "Accounts mit Moderationsrechten:"
 
-#: uffd/rolemod/templates/rolemod/show.html:42
+#: uffd/rolemod/templates/rolemod/show.html:40
 msgid "Role members:"
 msgstr "Mitglieder:"
 
-#: uffd/rolemod/templates/rolemod/show.html:55
+#: uffd/rolemod/templates/rolemod/show.html:53
 msgid "Remove"
 msgstr "Entfernen"
 
@@ -957,28 +970,27 @@ msgstr ""
 "Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an "
 "die Adresse gesendet."
 
-#: uffd/selfservice/views.py:91 uffd/selfservice/views.py:100
-#: uffd/selfservice/views.py:132 uffd/selfservice/views.py:142
+#: uffd/selfservice/views.py:87 uffd/selfservice/views.py:116
 msgid "Token expired, please try again."
 msgstr "Link abgelaufen, bitte versuche es erneut."
 
-#: uffd/selfservice/views.py:108
+#: uffd/selfservice/views.py:95
 msgid "You need to set a password, please try again."
 msgstr "Password fehlt, bitte versuche es erneut."
 
-#: uffd/selfservice/views.py:111
+#: uffd/selfservice/views.py:98
 msgid "Passwords do not match, please try again."
 msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut"
 
-#: uffd/selfservice/views.py:116
+#: uffd/selfservice/views.py:103
 msgid "Password ist not valid, please try again."
 msgstr "Ungültiges Passwort, bitte versuche es erneut"
 
-#: uffd/selfservice/views.py:119
+#: uffd/selfservice/views.py:106
 msgid "New password set"
 msgstr "Passwort geändert"
 
-#: uffd/selfservice/views.py:148
+#: uffd/selfservice/views.py:122
 msgid ""
 "This link was generated for another user. Login as the correct user to "
 "continue."
@@ -986,20 +998,16 @@ msgstr ""
 "Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem "
 "richtigen Account an um Fortzufahren."
 
-#: uffd/selfservice/views.py:150
+#: uffd/selfservice/views.py:124
 msgid "New mail set"
 msgstr "E-Mail-Adresse geändert"
 
-#: uffd/selfservice/views.py:160
-msgid "Leaving roles is disabled"
-msgstr "Verlassen von Rollen ist deaktiviert"
-
-#: uffd/selfservice/views.py:166
+#: uffd/selfservice/views.py:137
 #, python-format
 msgid "You left role %(role_name)s"
 msgstr "Rolle %(role_name)s verlassen"
 
-#: uffd/selfservice/views.py:177 uffd/selfservice/views.py:194
+#: uffd/selfservice/views.py:148 uffd/selfservice/views.py:165
 #, python-format
 msgid "Mail to \"%(mail_address)s\" could not be sent!"
 msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
@@ -1137,17 +1145,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:94
+#: uffd/selfservice/templates/selfservice/self.html:93
 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:96
-msgid "Administrators can add new roles to your account."
-msgstr "Accounts mit Adminrechten können dich zu neuen Rollen hinzufügen."
-
-#: uffd/selfservice/templates/selfservice/self.html:111
+#: uffd/selfservice/templates/selfservice/self.html:107
 msgid ""
 "Some permissions in this role require you to setup two-factor "
 "authentication"
@@ -1155,11 +1159,11 @@ msgstr ""
 "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
 "Faktor-Authentifikation"
 
-#: uffd/selfservice/templates/selfservice/self.html:118
+#: uffd/selfservice/templates/selfservice/self.html:113
 msgid "Leave"
 msgstr "Verlassen"
 
-#: uffd/selfservice/templates/selfservice/self.html:126
+#: uffd/selfservice/templates/selfservice/self.html:120
 msgid "You currently don't have any roles"
 msgstr "Du hast derzeit keine Rollen"
 
@@ -1323,28 +1327,28 @@ msgstr "Über anderes Gerät anmelden"
 msgid "Register"
 msgstr "Registrieren"
 
-#: uffd/session/templates/session/login.html:30
+#: uffd/session/templates/session/login.html:29
 msgid "Forgot Password?"
 msgstr "Passwort vergessen?"
 
-#: uffd/signup/views.py:24
+#: uffd/signup/views.py:23
 msgid "Singup not enabled"
 msgstr "Account-Registrierung ist deaktiviert"
 
-#: uffd/signup/views.py:82 uffd/signup/views.py:91 uffd/signup/views.py:99
+#: uffd/signup/views.py:77 uffd/signup/views.py:85
 msgid "Invalid signup link"
 msgstr "Ungültiger Account-Registrierungs-Link"
 
-#: uffd/signup/views.py:104
+#: uffd/signup/views.py:90
 #, 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:110
+#: uffd/signup/views.py:96
 msgid "Wrong password"
 msgstr "Falsches Passwort"
 
-#: uffd/signup/views.py:116
+#: uffd/signup/views.py:102
 msgid "Your account was successfully created"
 msgstr "Account erfolgreich erstellt"
 
@@ -1365,6 +1369,7 @@ msgid "Check"
 msgstr "Überprüfen"
 
 #: uffd/signup/templates/signup/start.html:23
+#: 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>"
@@ -1455,19 +1460,23 @@ msgstr ""
 msgid "Groups"
 msgstr "Gruppen"
 
-#: uffd/user/views_group.py:51
+#: 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:56
+#: uffd/user/views_group.py:58
 msgid "Group created"
 msgstr "Gruppe erstellt"
 
-#: uffd/user/views_group.py:58
+#: uffd/user/views_group.py:60
 msgid "Group updated"
 msgstr "Gruppe aktualisiert"
 
-#: uffd/user/views_group.py:67
+#: uffd/user/views_group.py:69
 msgid "Deleted group"
 msgstr "Gruppe gelöscht"
 
-- 
GitLab