diff --git a/src/api/schedule.py b/src/api/schedule.py
index 104128c13ea107f3e86b6d766b4b7e9985986c6c..734c8bd22f10eb3bd73ef1c60de5cc830b650e1f 100644
--- a/src/api/schedule.py
+++ b/src/api/schedule.py
@@ -109,6 +109,7 @@ class ScheduleEncoder(json.JSONEncoder):
             return {'id': None, 'name': p, 'public_name': p}
 
         if isinstance(p, PlatformUser):
+            # TODO: Update after deciding oh one or more conferences in #648
             member: ConferenceMember = p.conferences.first()  # TODO search for correct conference
             name = p.get_display_name()
 
diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po
index 858a6f9497052aa278c58387038dbb102db2add5..cbaabc6018b19134411331c419a4790372abb32f 100644
--- a/src/backoffice/locale/de/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/de/LC_MESSAGES/django.po
@@ -95,64 +95,9 @@ msgstr "Hangar-Zugriff"
 msgid "Room__hangar_backend_link__help"
 msgstr "Private FTP-Zugang um Dateien im Hangar abzulegen"
 
-msgid "ActivityLog__visible_entries__all"
-msgstr "alle Einträge"
-
-msgid "ActivityLog__visible_entries__msg"
-msgstr "nur mit Notiz"
-
-msgid "ActivityLog_add_comment"
-msgstr "Kommentar (als Log-Eintrag) hinzufügen"
-
-msgid "ActivityLogEntries"
-msgstr "Activity Log Einträge"
-
-msgid "ActivityLog_add_comment_help"
-msgstr "Kommentar, der im Log-Eintrag vermerkt wird (nur für Orga sichtbar):"
-
-msgid "ActivityLog_add_comment_do"
-msgstr "Speichern"
-
 msgid "assembly_children"
 msgstr "Zugeordnete Assemblies"
 
-msgid "Assembly__invitation_pending__title"
-msgstr "Ausstehende Einladungen"
-
-#, python-format
-msgid "Assembly__invitation__habitat_assembly %(habitat)s"
-msgstr "Das Habitat \"%(habitat)s\" lädt euch ein beizutreten."
-
-#, python-format
-msgid "Assembly__invitation__assembly_habitat %(assembly)s"
-msgstr "Das Assembly \"%(assembly)s\" möchte eurem Habitat beitreten."
-
-msgid "Invitation__Habitat__action__detail"
-msgstr "Details"
-
-msgid "Invitation__Habitat__action__accept"
-msgstr "Akzeptieren"
-
-msgid "Invitation__Habitat__action__reject"
-msgstr "Ablehnen"
-
-msgid "Assembly__invitation_sent__title"
-msgstr "Versendete Einladungen"
-
-#, python-format
-msgid "Assembly__invitation_waiting__assembly_habitat %(habitat)s"
-msgstr "Ihr hab angefragt dem Habitat \"%(habitat)s\" beizutreten."
-
-#, python-format
-msgid "Assembly__invitation_waiting__habitat_assembly %(assembly)s"
-msgstr "Das Assembly \"%(assembly)s\" wurde eingeladen dem Habitat beizutreten."
-
-msgid "Invitation__Habitat__action__edit"
-msgstr "Bearbeiten"
-
-msgid "Invitation__Habitat__action__withdraw"
-msgstr "Zurückziehen"
-
 msgid "Assembly__additional_field__required"
 msgstr "Dieses Feld darf nicht leer bleiben."
 
@@ -407,6 +352,12 @@ msgstr "Habitat verlassen"
 msgid "assembly__parent__request"
 msgstr "Antrag auf Beitritt zu einem Habitat"
 
+msgid "Invitation__list_pending__title"
+msgstr "Ausstehende Einladungen"
+
+msgid "Invitation__list_sent__title"
+msgstr "Versendete Einladungen"
+
 msgid "Assembly__edit__base_data"
 msgstr "Allgemeine Angaben"
 
@@ -524,39 +475,6 @@ msgstr "Hiermit wird diese Veranstaltung gelöscht. Wenn du sie nur unsichtbar m
 msgid "Events"
 msgstr ""
 
-msgid "assembly_info_no_data"
-msgstr "Bisher keine Daten vorhanden"
-
-msgid "assembly_info_paginated"
-msgstr "Zeige _START_ bis _END_ von _TOTAL_ Einträgen"
-
-msgid "assembly_info_empty"
-msgstr "Zeige 0 Einträge"
-
-msgid "assembly_info_filtered"
-msgstr "(gefiltert, insgesamt _MAX_ Einträge)"
-
-msgid "assembly_paginate_menu"
-msgstr "Zeige _MENU_ Einträge"
-
-msgid "assembly_search"
-msgstr "Suche:"
-
-msgid "assembly_no_entries"
-msgstr "Keine Einträge."
-
-msgid "assembly_paginate_first"
-msgstr "Erste"
-
-msgid "assembly_paginate_last"
-msgstr "Letzte"
-
-msgid "assembly_paginate_next"
-msgstr "Nächste"
-
-msgid "assembly_paginate_previous"
-msgstr "Vorherige"
-
 msgid "Assemblies"
 msgstr ""
 
@@ -843,6 +761,10 @@ msgstr "Karte"
 msgid "nav_moderation"
 msgstr "Moderation"
 
+# Use translation from core
+msgid "Teams"
+msgstr ""
+
 msgid "nav_conference_admin"
 msgstr "Konferenz"
 
@@ -852,6 +774,10 @@ msgstr "Wiki"
 msgid "nav_schedules"
 msgstr "Schedules"
 
+# Use translation from core
+msgid "Invitations"
+msgstr ""
+
 msgid "Conference__Selection"
 msgstr "Konferenz auswählen"
 
@@ -876,6 +802,46 @@ msgstr "Übersicht"
 msgid "backoffice__not_a_conference_member"
 msgstr "Das Nutzerkonto, mit dem du gerade angemeldet bist, ist zur Zeit nicht mit einem Veranstaltungsticket verknüpft. Wenn Du gerade beim Aufbau hilfst, ist das kein Problem. Denk aber bitte daran, rechtzeitig ein Ticket zu kaufen und dies kurz vor Veranstaltungsbeginn mit deinem Nutzerkonto zu verknüpfen. Ansonsten kannst Du mit diesem Nutzerkonto nicht an der Veranstaltung teilnehmen."
 
+msgid "ActivityLogEntries"
+msgstr "Activity Log Einträge"
+
+msgid "ActivityLog__visible_entries__all"
+msgstr "alle Einträge"
+
+msgid "ActivityLog__visible_entries__msg"
+msgstr "nur mit Notiz"
+
+msgid "ActivityLog_add_comment"
+msgstr "Kommentar (als Log-Eintrag) hinzufügen"
+
+msgid "ActivityLog_add_comment_do"
+msgstr "Speichern"
+
+msgid "ActivityLog_no_entries"
+msgstr "Keine Activity Log Einträge"
+
+msgid "Cancel"
+msgstr "Abbrechen"
+
+#, python-format
+msgid "Invitation__description %(requester)s %(requested)s %(type)s"
+msgstr "Die Einladung von  \"%(requester)s\" an \"%(requested)s\" (Art: %(type)s)"
+
+msgid "Invitation__action__edit"
+msgstr "Bearbeiten"
+
+msgid "Invitation__action__withdraw"
+msgstr "Zurückziehen"
+
+msgid "Invitation__action__detail"
+msgstr "Details"
+
+msgid "Invitation__action__accept"
+msgstr "Akzeptieren"
+
+msgid "Invitation__action__reject"
+msgstr "Ablehnen"
+
 msgid "create"
 msgstr "Erstellen"
 
@@ -985,20 +951,17 @@ msgstr "Registrierung nicht mehr möglich"
 msgid "welcome_text"
 msgstr "Hier können Assemblies ihre Mitglieder, Veranstaltungen und Räume pflegen. Bitte einloggen."
 
-msgid "Invitation__Habitat__type"
-msgstr "Habitatseinladung"
-
-msgid "Invitation__Habitat__title"
-msgstr "Einladung zum Habitatsbeitritt"
+msgid "Invitation__action__submit"
+msgstr "Einladung senden"
 
-msgid "Invitation__Habitat__introduction"
-msgstr "Mit dieser Einladung wird eine Aufnahme des Assemblies in das Habitat angefordert. Wenn die Einladung angenommen wird, wird das Assembly dem Habitat zugeordnet."
+msgid "Invitation__action__update"
+msgstr "Aktualisieren"
 
-msgid "Invitation__Habitat__action__submit"
-msgstr "Einladung absenden"
+msgid "No comment"
+msgstr ""
 
-msgid "Invitation__Habitat__action__update"
-msgstr "Aktualisieren"
+msgid "Invitations__list__title"
+msgstr "Ausstehende Einladungen"
 
 msgid "Links"
 msgstr "Links"
@@ -1487,6 +1450,164 @@ msgstr "Self-organized Session löschen"
 msgid "SoS__delete__introduction"
 msgstr "Hiermit wird diese Self-organized Session gelöscht. Wenn du sie nur unsichtbar machen möchtest gibt es weiter oben die Möglichkeit die Self-organized Session zurückzunehmen!"
 
+# use translation from core
+msgid "Team"
+msgstr ""
+
+msgid "Team__create__title"
+msgstr "Neues Team erstellen"
+
+#, python-format
+msgid "Team__edit__title %(form.instance.name)s"
+msgstr "Team %(form.instance.name)s bearbeiten"
+
+msgid "team__edit__metadata"
+msgstr "Team-Daten"
+
+# Use translation from core
+msgid "Team__require_staff"
+msgstr ""
+
+# Use translation from core
+msgid "Team__require_staff__help"
+msgstr ""
+
+msgid "Team__change_permission_admin"
+msgstr "Du kannst die mit dem Team verknüpften Berechtigungen hier bearbeiten: "
+
+msgid "Team__edit__submit"
+msgstr "Speichern"
+
+msgid "Details"
+msgstr ""
+
+msgid "Team__delete__warning__header"
+msgstr "Das Team löschen?"
+
+#, python-format
+msgid "Team__delete__warning__text %(team)s"
+msgstr "Das Team \"%(team)s\" wirklich löschen?"
+
+msgid "Team__delete__submit"
+msgstr "Team löschen"
+
+# Use translation from core
+msgid "Team__name"
+msgstr ""
+
+# Use translation from core
+msgid "Team__description"
+msgstr ""
+
+msgid "Team__no_description"
+msgstr "Dieses Team hat aktuell keine Beschreibung"
+
+msgid "Team__edit__button"
+msgstr "Team bearbeiten"
+
+# Use translation from core
+msgid "TeamMembers"
+msgstr ""
+
+msgid "name"
+msgstr "Name"
+
+# Use translation from core
+msgid "TeamMember__can_manage"
+msgstr ""
+
+# Use translation from core
+msgid "TeamMember__created"
+msgstr ""
+
+# Use translation from core
+msgid "TeamMember__updated"
+msgstr ""
+
+msgid "TeamMember__actions"
+msgstr "Aktionen"
+
+msgid "TeamMember__demote_button"
+msgstr "Verwaltungsrechte entfernen"
+
+msgid "TeamMember__promote_button"
+msgstr "Verwaltungsrechte verleihen"
+
+msgid "TeamMember__remove__button"
+msgstr "Entfernen"
+
+msgid "TeamMember__leave"
+msgstr "Team verlassen"
+
+msgid "TeamMember__join"
+msgstr "Beitritt beantragen"
+
+msgid "TeamMember__invite"
+msgstr "Personen einladen"
+
+msgid "Team__delete__title"
+msgstr "Team löschen"
+
+msgid "Team__delete__introduction"
+msgstr "Hiermit wird dieses Team gelöscht. Dies kann nicht rückgängig gemacht werden."
+
+msgid "Team__delete__button"
+msgstr "Team löschen"
+
+msgid "Team__member_count"
+msgstr "Mitgliederanzahl"
+
+msgid "Team__list__not_a_member"
+msgstr "Kein Mitglied"
+
+msgid "Team__create__button"
+msgstr "Team anlegen"
+
+msgid "data_table__info__no_data"
+msgstr "Bisher keine Daten vorhanden"
+
+msgid "data_table__info__paginated"
+msgstr "Zeige _START_ bis _END_ von _TOTAL_ Einträgen"
+
+msgid "data_table__info__empty"
+msgstr "Zeige 0 Einträge"
+
+msgid "data_table__info__filtered"
+msgstr "(gefiltert, insgesamt _MAX_ Einträge)"
+
+msgid "data_table__paginate__menu"
+msgstr "Zeige _MENU_ Einträge"
+
+msgid "data_table__loading"
+msgstr "Laden im Gange..."
+
+msgid "data_table__processing"
+msgstr "Verarbeitung...."
+
+msgid "data_table_search"
+msgstr "Suche:"
+
+msgid "data_table__no_entries"
+msgstr "Keine Einträge."
+
+msgid "data_table__paginate__first"
+msgstr "Erste"
+
+msgid "data_table__paginate__last"
+msgstr "Letzte"
+
+msgid "data_table__paginate__next"
+msgstr "Nächste"
+
+msgid "data_table__paginate__previous"
+msgstr "Vorherige"
+
+msgid "data_table__aria__sort_ascending"
+msgstr "Aufsteigend nach dieser Spalte sortieren"
+
+msgid "data_table__aria__sort_descending"
+msgstr "Absteigend nach dieser Spalte sortieren"
+
 msgid "StaticPage-lock_drop"
 msgstr "Seitensperre aufheben"
 
@@ -1602,6 +1723,20 @@ msgstr "Account angelegt"
 msgid "registration_sign_up_mail_sent"
 msgstr "Eine E-Mail mit einem Aktivierungs-Link wurde an die angegebene E-Mail-Adresse gesendet. Bitte prüfe deine Inbox und öffne den in der Mail enthaltenen Link um deinen Account zu aktivieren."
 
+msgid "TeamMember__delete__warning__header"
+msgstr "Benutzer aus dem Team entfernen?"
+
+msgid "TeamMember__leave__warning__header"
+msgstr "Das Team wirklich verlassen?"
+
+# use translation from core
+msgid "TeamMember__delete__cannot_delete_last_manager"
+msgstr ""
+
+# use translation from core
+msgid "TeamMember__clean__cannot_remove_last_manager"
+msgstr ""
+
 msgid "assembly__created"
 msgstr "Assembly wurde angelegt. Bitte fülle die weiteren Felder aus um die Registrierung abzuschließen."
 
@@ -1730,6 +1865,9 @@ msgstr "versteckt"
 msgid "lists"
 msgstr "Listen"
 
+msgid "ActivityLog__add_comment__AssemblyTeam"
+msgstr "Kommentar, der im Log-Eintrag vermerkt wird (nur für Orga sichtbar):"
+
 msgid "AssemblyTeam__add_comment_success"
 msgstr "Kommentar wurde als Log-Eintrag gespeichert."
 
@@ -1888,6 +2026,31 @@ msgstr "Konfigurationsfehler: Self-organized Sessions können noch nicht angeleg
 msgid "SoS__removed %(event_name)s"
 msgstr "Self-organized Session %(event_name)s gelöscht"
 
+msgid "Invitation__title__habitat"
+msgstr "Einladung zum Habitatsbeitritt"
+
+msgid "Invitation__introduction__habitat"
+msgstr "Mit dieser Einladung wird eine Aufnahme des Assemblies in das Habitat angefordert. Wenn die Einladung angenommen wird, wird das Assembly dem Habitat zugeordnet."
+
+# use translation from core
+msgid "Invitation__type__team_to_member"
+msgstr ""
+
+msgid "Invitation__introduction__team"
+msgstr "Mit dieser Einladung wird eine Aufnahme des Benutzers in das Team angefordert."
+
+# use translation from core
+msgid "TeamMember"
+msgstr ""
+
+# use translation from core
+msgid "Invitation__type__member_to_team"
+msgstr ""
+
+# use translation from core
+msgid "Invitation__type__habitat"
+msgstr ""
+
 #, python-format
 msgid "Invitation__updated__message %(requester)s %(requested)s"
 msgstr "Die Einladung von \"%(requester)s\" an \"%(requested)s\" ist aktualisiert worden"
@@ -1957,6 +2120,31 @@ msgstr "%(project_type)s '%(project)s' erstellt"
 msgid "Project__updated %(project)s %(project_type)s"
 msgstr "%(project_type)s '%(project)s' aktualisiert"
 
+#, python-format
+msgid "TeamMember__leave__warning__text %(team)s"
+msgstr "Das Team \"%(team)s\" wirklich verlassen?"
+
+msgid "TeamMember__leave__submit"
+msgstr "Team verlassen"
+
+#, python-format
+msgid "TeamMember__delete__warning__text %(team)s %(user)s"
+msgstr "Den Benutzer \"%(user)s\" aus dem Team \"%(team)s\" entfernen?"
+
+msgid "TeamMember__delete__submit"
+msgstr "Benutzer entfernen"
+
+#, python-format
+msgid "Team__create__success %(name)s"
+msgstr "Das Team \"%(name)s\" wurde angelegt!"
+
+#, python-format
+msgid "Team__update__success %(name)s"
+msgstr "Das Aktualisieren des Teams \"%(name)s\" war erfolgreich!"
+
+msgid "Team__delete__success"
+msgstr "Das Team wurde gelöscht."
+
 msgid "Lock-gone"
 msgstr "Sperre bestand nicht mehr."
 
diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po
index 4318daf34a38e5277aaac535ebe0e4f259eca252..0fc4383642a5f93d926fe92774b43108e4e355c1 100644
--- a/src/backoffice/locale/en/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/en/LC_MESSAGES/django.po
@@ -95,64 +95,9 @@ msgstr "Hangar Access"
 msgid "Room__hangar_backend_link__help"
 msgstr "your private ftp access to put files on your hangar"
 
-msgid "ActivityLog__visible_entries__all"
-msgstr "all entries"
-
-msgid "ActivityLog__visible_entries__msg"
-msgstr "only w/ note"
-
-msgid "ActivityLog_add_comment"
-msgstr "add comment as log entry"
-
-msgid "ActivityLogEntries"
-msgstr "activity log entries"
-
-msgid "ActivityLog_add_comment_help"
-msgstr "comment which will be added as a log entry (visible for orga only)"
-
-msgid "ActivityLog_add_comment_do"
-msgstr "save"
-
 msgid "assembly_children"
 msgstr "Grouped Assemblies"
 
-msgid "Assembly__invitation_pending__title"
-msgstr "pending invitations"
-
-#, python-format
-msgid "Assembly__invitation__habitat_assembly %(habitat)s"
-msgstr "The habitat \"%(habitat)s\" invites you to join."
-
-#, python-format
-msgid "Assembly__invitation__assembly_habitat %(assembly)s"
-msgstr "The assembly \"%(assembly)s\" would like to join your habitat."
-
-msgid "Invitation__Habitat__action__detail"
-msgstr "Details"
-
-msgid "Invitation__Habitat__action__accept"
-msgstr "Accept"
-
-msgid "Invitation__Habitat__action__reject"
-msgstr "Reject"
-
-msgid "Assembly__invitation_sent__title"
-msgstr "sent invitations"
-
-#, python-format
-msgid "Assembly__invitation_waiting__assembly_habitat %(habitat)s"
-msgstr "You have requested to join the habitat \"%(habitat)s\"."
-
-#, python-format
-msgid "Assembly__invitation_waiting__habitat_assembly %(assembly)s"
-msgstr "The assembly \"%(assembly)s\" was invited to join the habitat."
-
-msgid "Invitation__Habitat__action__edit"
-msgstr "Edit"
-
-msgid "Invitation__Habitat__action__withdraw"
-msgstr "Withdraw"
-
 msgid "Assembly__additional_field__required"
 msgstr "This field must not be empty."
 
@@ -407,6 +352,12 @@ msgstr "leave habitat"
 msgid "assembly__parent__request"
 msgstr "request to join habitat"
 
+msgid "Invitation__list_pending__title"
+msgstr "Pending invitations"
+
+msgid "Invitation__list_sent__title"
+msgstr "Sent invitations"
+
 msgid "Assembly__edit__base_data"
 msgstr "Basic data"
 
@@ -524,39 +475,6 @@ msgstr "This deletes this event. If you only want to make it invisible, there is
 msgid "Events"
 msgstr ""
 
-msgid "assembly_info_no_data"
-msgstr "No data available in table"
-
-msgid "assembly_info_paginated"
-msgstr "Showing _START_ to _END_ of _TOTAL_ entries"
-
-msgid "assembly_info_empty"
-msgstr "Showing 0 out of 0 entries"
-
-msgid "assembly_info_filtered"
-msgstr "(filtered from _MAX_ total entries)"
-
-msgid "assembly_paginate_menu"
-msgstr "Show _MENU_ entries"
-
-msgid "assembly_search"
-msgstr "Search:"
-
-msgid "assembly_no_entries"
-msgstr "No entries."
-
-msgid "assembly_paginate_first"
-msgstr "First"
-
-msgid "assembly_paginate_last"
-msgstr "Last"
-
-msgid "assembly_paginate_next"
-msgstr "Next"
-
-msgid "assembly_paginate_previous"
-msgstr "Previous"
-
 msgid "Assemblies"
 msgstr ""
 
@@ -843,6 +761,10 @@ msgstr "map"
 msgid "nav_moderation"
 msgstr "moderation"
 
+# Use translation from core
+msgid "Teams"
+msgstr ""
+
 msgid "nav_conference_admin"
 msgstr "Conference"
 
@@ -852,6 +774,10 @@ msgstr "Wiki"
 msgid "nav_schedules"
 msgstr "Schedules"
 
+# use translation from core
+msgid "Invitations"
+msgstr ""
+
 msgid "Conference__Selection"
 msgstr "Select Conference"
 
@@ -876,6 +802,46 @@ msgstr "overview"
 msgid "backoffice__not_a_conference_member"
 msgstr "The user account you are currently logged in with is not linked to an valid event ticket. If you are helping with the set-up, this is not a problem. But please remember to buy a ticket in time and link it to your user account. Otherwise you will not be able to participate in the event with this user account. Linking the ticket to the user account will be available directly before the event."
 
+msgid "ActivityLogEntries"
+msgstr "activity log entries"
+
+msgid "ActivityLog__visible_entries__all"
+msgstr "all entries"
+
+msgid "ActivityLog__visible_entries__msg"
+msgstr "only w/ note"
+
+msgid "ActivityLog_add_comment"
+msgstr "add comment as log entry"
+
+msgid "ActivityLog_add_comment_do"
+msgstr "save"
+
+msgid "ActivityLog_no_entries"
+msgstr "No activity log entries"
+
+msgid "Cancel"
+msgstr ""
+
+#, python-format
+msgid "Invitation__description %(requester)s %(requested)s %(type)s"
+msgstr "Invitation from \"%(requester)s\" to \"%(requested)s\" (type: %(type)s)"
+
+msgid "Invitation__action__edit"
+msgstr "Edit"
+
+msgid "Invitation__action__withdraw"
+msgstr "Withdraw"
+
+msgid "Invitation__action__detail"
+msgstr "Details"
+
+msgid "Invitation__action__accept"
+msgstr "Accept"
+
+msgid "Invitation__action__reject"
+msgstr "Reject"
+
 msgid "create"
 msgstr ""
 
@@ -986,21 +952,18 @@ msgstr "registration deadline exceeded"
 msgid "welcome_text"
 msgstr "Here, assemblies can manage their members, events and rooms. Please login."
 
-msgid "Invitation__Habitat__type"
-msgstr "Habitat invitation"
-
-msgid "Invitation__Habitat__title"
-msgstr "Invitation to join a habitat"
-
-msgid "Invitation__Habitat__introduction"
-msgstr "This invitation requests that the assembly be included in the habitat. If the invitation is accepted, the assembly is assigned to the habitat."
-
-msgid "Invitation__Habitat__action__submit"
+msgid "Invitation__action__submit"
 msgstr "Send invitation"
 
-msgid "Invitation__Habitat__action__update"
+msgid "Invitation__action__update"
 msgstr "Update"
 
+msgid "No comment"
+msgstr ""
+
+msgid "Invitations__list__title"
+msgstr "Open invitations"
+
 msgid "Links"
 msgstr "Links"
 
@@ -1492,6 +1455,164 @@ msgstr "Delete self-organized session"
 msgid "SoS__delete__introduction"
 msgstr "This removes this self-organized session. If you only want to make it invisible, there is an option above to recall the self-organized session above!"
 
+# use translation from core
+msgid "Team"
+msgstr ""
+
+msgid "Team__create__title"
+msgstr "Create team"
+
+#, python-format
+msgid "Team__edit__title %(form.instance.name)s"
+msgstr "Edit the %(form.instance.name)s team"
+
+msgid "team__edit__metadata"
+msgstr "team data"
+
+# Use translation from core
+msgid "Team__require_staff"
+msgstr ""
+
+# Use translation from core
+msgid "Team__require_staff__help"
+msgstr ""
+
+msgid "Team__change_permission_admin"
+msgstr "You are allowed to change the teams permissions at: "
+
+msgid "Team__edit__submit"
+msgstr "Save"
+
+msgid "Details"
+msgstr ""
+
+msgid "Team__delete__warning__header"
+msgstr "Delete the team?"
+
+#, python-format
+msgid "Team__delete__warning__text %(team)s"
+msgstr "Are you sure you want to delete the team \"%(team)s\"?"
+
+msgid "Team__delete__submit"
+msgstr "Delete team"
+
+# Use translation from core
+msgid "Team__name"
+msgstr ""
+
+# Use translation from core
+msgid "Team__description"
+msgstr "description"
+
+msgid "Team__no_description"
+msgstr "This team has no description at the moment."
+
+msgid "Team__edit__button"
+msgstr "Edit team"
+
+# Use translation from core
+msgid "TeamMembers"
+msgstr ""
+
+msgid "name"
+msgstr ""
+
+# Use translation from core
+msgid "TeamMember__can_manage"
+msgstr ""
+
+# Use translation from core
+msgid "TeamMember__created"
+msgstr ""
+
+# Use translation from core
+msgid "TeamMember__updated"
+msgstr ""
+
+msgid "TeamMember__actions"
+msgstr "Actions"
+
+msgid "TeamMember__demote_button"
+msgstr "Remote management rights"
+
+msgid "TeamMember__promote_button"
+msgstr "Add management rights"
+
+msgid "TeamMember__remove__button"
+msgstr "Remove"
+
+msgid "TeamMember__leave"
+msgstr "Leave team"
+
+msgid "TeamMember__join"
+msgstr "Request to join"
+
+msgid "TeamMember__invite"
+msgstr "Invite members"
+
+msgid "Team__delete__title"
+msgstr "Delete this team"
+
+msgid "Team__delete__introduction"
+msgstr "This deletes this Team. This action cannot be reversed!"
+
+msgid "Team__delete__button"
+msgstr "Delete team"
+
+msgid "Team__member_count"
+msgstr "members count"
+
+msgid "Team__list__not_a_member"
+msgstr "Not a member"
+
+msgid "Team__create__button"
+msgstr "Create team"
+
+msgid "data_table__info__no_data"
+msgstr "No data available in table"
+
+msgid "data_table__info__paginated"
+msgstr "Showing _START_ to _END_ of _TOTAL_ entries"
+
+msgid "data_table__info__empty"
+msgstr "Showing 0 out of 0 entries"
+
+msgid "data_table__info__filtered"
+msgstr "(filtered from _MAX_ total entries)"
+
+msgid "data_table__paginate__menu"
+msgstr "Show _MENU_ entries"
+
+msgid "data_table__loading"
+msgstr "Loading..."
+
+msgid "data_table__processing"
+msgstr "Processing..."
+
+msgid "data_table_search"
+msgstr "Search:"
+
+msgid "data_table__no_entries"
+msgstr "No entries."
+
+msgid "data_table__paginate__first"
+msgstr "First"
+
+msgid "data_table__paginate__last"
+msgstr "Last"
+
+msgid "data_table__paginate__next"
+msgstr "Next"
+
+msgid "data_table__paginate__previous"
+msgstr "Previous"
+
+msgid "data_table__aria__sort_ascending"
+msgstr "activate to sort column ascending"
+
+msgid "data_table__aria__sort_descending"
+msgstr "activate to sort column descending"
+
 msgid "StaticPage-lock_drop"
 msgstr "drop page lock"
 
@@ -1608,6 +1729,20 @@ msgstr "Account created."
 msgid "registration_sign_up_mail_sent"
 msgstr "An email with an activation link has been sent to the given e-mail address. Please check your inbox and open the link sent to you in order to activate your account."
 
+msgid "TeamMember__delete__warning__header"
+msgstr "Remove user from the team?"
+
+msgid "TeamMember__leave__warning__header"
+msgstr "Do you really want to leave the team?"
+
+# use translation from core
+msgid "TeamMember__delete__cannot_delete_last_manager"
+msgstr "Remove user from the team?"
+
+# use translation from core
+msgid "TeamMember__clean__cannot_remove_last_manager"
+msgstr ""
+
 msgid "assembly__created"
 msgstr "Assembly has been created. Please fill out the following extra information to complete registration."
 
@@ -1736,6 +1871,9 @@ msgstr "hidden"
 msgid "lists"
 msgstr "lists"
 
+msgid "ActivityLog__add_comment__AssemblyTeam"
+msgstr "comment which will be added as a log entry (visible for orga only)"
+
 msgid "AssemblyTeam__add_comment_success"
 msgstr "A log entry with the comment has been added."
 
@@ -1893,6 +2031,31 @@ msgstr "Configuration Error: Self-organized sessions cannot be created yet!"
 msgid "SoS__removed %(event_name)s"
 msgstr "Self-organized Session %(event_name)s deleted"
 
+msgid "Invitation__title__habitat"
+msgstr "Invitation to join a habitat"
+
+msgid "Invitation__introduction__habitat"
+msgstr "This invitation requests that the assembly be included in the habitat. If the invitation is accepted, the assembly is assigned to the habitat."
+
+# use translation from core
+msgid "Invitation__type__team_to_member"
+msgstr ""
+
+msgid "Invitation__introduction__team"
+msgstr "This invitation requests that a user joins/is added to a team."
+
+# use translation from core
+msgid "TeamMember"
+msgstr ""
+
+# use translation from core
+msgid "Invitation__type__member_to_team"
+msgstr ""
+
+# use translation from core
+msgid "Invitation__type__habitat"
+msgstr ""
+
 #, python-format
 msgid "Invitation__updated__message %(requester)s %(requested)s"
 msgstr "The invitation from \"%(requester)s\" to \"%(requested)s\" has been updated"
@@ -1962,6 +2125,31 @@ msgstr "%(project_type)s '%(project)s' created"
 msgid "Project__updated %(project)s %(project_type)s"
 msgstr "%(project_type)s '%(project)s' updated"
 
+#, python-format
+msgid "TeamMember__leave__warning__text %(team)s"
+msgstr "Really leave the team \"%(team)s\"?"
+
+msgid "TeamMember__leave__submit"
+msgstr "Leave the team"
+
+#, python-format
+msgid "TeamMember__delete__warning__text %(team)s %(user)s"
+msgstr "Remove the user \"%(user)s\" from the team \"%(team)s\"?"
+
+msgid "TeamMember__delete__submit"
+msgstr "Remove user"
+
+#, python-format
+msgid "Team__create__success %(name)s"
+msgstr "The team \"%(name)s\" was successfully created."
+
+#, python-format
+msgid "Team__update__success %(name)s"
+msgstr "The team \"%(name)s\" was successfully updated."
+
+msgid "Team__delete__success"
+msgstr "Team was deleted."
+
 msgid "Lock-gone"
 msgstr "Lock was gone already."
 
diff --git a/src/backoffice/templates/backoffice/activitylog_card.html b/src/backoffice/templates/backoffice/activitylog_card.html
deleted file mode 100644
index 97951263b91bfe5f8da39fc2a91c1d3c516b69ea..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/activitylog_card.html
+++ /dev/null
@@ -1,123 +0,0 @@
-{% load c3assemblies %}
-{% load i18n %}
-{% load humanize %}
-
-{% gen_rand_str as alc_ident %}
-<div class="row mb-3">
-  <div class="col-md-12">
-    <div class="card" id="logentries_{{ alc_ident }}">
-      <div class="card-header">
-        <div class="d-inline-block float-end">
-          <div class="btn-group d-none me-1"
-               role="group"
-               id="visible_logentries_{{ alc_ident }}">
-            <input type="radio"
-                   class="btn-check"
-                   name="visible_logentries"
-                   id="visible_logentries_all_{{ alc_ident }}"
-                   autocomplete="off"
-                   checked>
-            <label class="btn btn-sm btn-outline-primary"
-                   for="visible_logentries_all_{{ alc_ident }}">{% trans "ActivityLog__visible_entries__all" %}</label>
-
-            <input type="radio"
-                   class="btn-check"
-                   name="visible_logentries"
-                   id="visible_logentries_msg_{{ alc_ident }}"
-                   autocomplete="off">
-            <label class="btn btn-sm btn-outline-primary"
-                   for="visible_logentries_msg_{{ alc_ident }}">{% trans "ActivityLog__visible_entries__msg" %}</label>
-          </div>
-          {% if add_comment_url %}
-            <button class="btn btn-sm btn-primary"
-                    title="{% trans "ActivityLog_add_comment" %}"
-                    data-bs-toggle="modal"
-                    data-bs-target="#addCommentModal_{{ alc_ident }}">
-              <i class="bi bi-chat-left-text"></i>
-            </button>
-          {% endif %}
-        </div>
-        {% trans "ActivityLogEntries" %}
-      </div>
-      <div class="card-body">
-        {% for entry in object.logentries.all %}
-          <div class="mb-3 {% if entry.kind == entry.Kind.SYSTEM %}text-muted{% endif %} logentry"
-               data-has-msg="{% if entry.comment %}y{% else %}n{% endif %}">
-            {% if latest_note and latest_note.pk == entry.pk %}<a id="latest_note"></a>{% endif %}
-            <div class="border-bottom border-secondary mb-1{% if not entry.comment %} text-muted{% endif %}">
-              <abbr title="{{ entry.timestamp }}">{{ entry.timestamp|naturaltime }}</abbr>
-              <span class="mx-1 badge bg-light text-bg-light border border-secondary-subtle"><i class="bi bi-person"></i> {{ entry.user.username }}</span>
-              <span class="text-muted">{{ entry.get_kind_display }}</span>
-            </div>
-            {% if entry.changes %}
-              <i class="bi bi-table float-start"></i>
-              <dl class="ms-4 mb-1 row small text-muted">
-                {% for k, v in entry.changes.items %}
-                  {% if k not in filtered_logentry_changes_keys %}
-                    <dt class="col-md-3">{{ k }}:</dt>
-                    <dd class="col-md-9">
-                      {% if v|is_dict %}
-                        <s class="fst-italic">{{ v.old }}</s>
-                        {{ v.new }}
-                      {% else %}
-                        {{ v }}
-                      {% endif %}
-                    </dd>
-                  {% endif %}
-                {% endfor %}
-              </dl>
-            {% endif %}
-            {% if entry.comment %}
-              <i class="bi bi-chat-left-text float-start"></i>
-              <div class="ms-4 border-1 bg-primary-subtle">{{ entry.comment }}</div>
-            {% endif %}
-          </div>
-        {% endfor %}
-      </div>
-    </div>
-  </div>
-</div>
-
-{% if add_comment_url %}
-  <div class="modal fade"
-       id="addCommentModal_{{ alc_ident }}"
-       tabindex="-1"
-       aria-labelledby="addCommentModalLabel"
-       aria-hidden="true">
-    <div class="modal-dialog">
-      <div class="modal-content">
-        <div class="modal-header">
-          <h1 class="modal-title fs-5" id="addCommentModalLabel">{% trans "ActivityLog_add_comment" %}</h1>
-          <button type="button"
-                  class="btn-close"
-                  data-bs-dismiss="modal"
-                  aria-label="Close"></button>
-        </div>
-        <div class="modal-body">
-          <p>{% trans "ActivityLog_add_comment_help" %}</p>
-          <form id="add_comment_{{ alc_ident }}"
-                action="{{ add_comment_url }}"
-                method="post">
-            {% csrf_token %}
-            <textarea name="comment" class="form-control" placeholder="" required></textarea>
-          </form>
-        </div>
-        <div class="modal-footer">
-          <button class="btn btn-primary" form="add_comment_{{ alc_ident }}">
-            <i class="bi bi-chat-left-text"></i> {% trans "ActivityLog_add_comment_do" %}
-          </button>
-        </div>
-      </div>
-    </div>
-  </div>
-{% endif %}
-
-<script nonce="{{ request.csp_nonce }}">
-  alc_div = document.getElementById("logentries_{{ alc_ident }}");
-  // make "visible log entries" selector visible and click the "w/ msg only" one
-  document.getElementById("visible_logentries_{{ alc_ident }}").classList.remove("d-none");
-  document.getElementById("visible_logentries_all_{{ alc_ident }}").addEventListener("click", function() { for (const el of alc_div.getElementsByClassName("logentry")) { el.classList.remove("d-none"); } });
-  let btn_msg = document.getElementById("visible_logentries_msg_{{ alc_ident }}");
-  btn_msg.addEventListener("click", function() { for (const el of alc_div.getElementsByClassName("logentry")) { if (el.getAttribute("data-has-msg") === "n") el.classList.add("d-none"); } });
-  btn_msg.click();
-</script>
diff --git a/src/backoffice/templates/backoffice/assemblies/components/invitations.html b/src/backoffice/templates/backoffice/assemblies/components/invitations.html
deleted file mode 100644
index eeb93defead58aee3e284d19fe663ffb69735dec..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/assemblies/components/invitations.html
+++ /dev/null
@@ -1,84 +0,0 @@
-{% load i18n %}
-
-{% if received_invitations %}
-  <div class="row mt-3">
-    <div class="col-md-12">
-      <div class="card border-primary">
-        <div class="card-header text-bg-primary">{% trans "Assembly__invitation_pending__title" %}</div>
-        <ul class="list-group">
-          {% for invitation in received_invitations %}
-            <li class="list-group-item">
-              {% if invitation.type == "HABITAT" and not assembly.is_cluster %}
-                {% blocktrans with habitat=invitation.requester.name %}Assembly__invitation__habitat_assembly {{habitat}}{% endblocktrans %}
-              {% elif invitation.type == "HABITAT" and assembly.is_cluster %}
-                {% blocktrans with assembly=invitation.requester.name %}Assembly__invitation__assembly_habitat {{assembly}}{% endblocktrans %}
-              {% else %}
-                {{ invitation.requester }}
-              {% endif %}
-              <span class="float-end">
-                <form method='post'
-                      action="{% url "backoffice:invitation" pk=invitation.pk %}">
-                  {% csrf_token %}
-                  <input type="hidden"
-                         name="next"
-                         value="{% url "backoffice:assembly" pk=assembly.pk %}">
-                  <a type="submit"
-                     class="btn btn-primary btn-sm"
-                     href="{% url "backoffice:invitation" pk=invitation.pk %}">{% trans "Invitation__Habitat__action__detail" %}</a>
-                  <button type="submit"
-                          name="action"
-                          value="accept"
-                          class="btn btn-success btn-sm">{% trans "Invitation__Habitat__action__accept" %}</button>
-                  <button type="submit"
-                          name="action"
-                          value="reject"
-                          class="btn btn-danger btn-sm">{% trans "Invitation__Habitat__action__reject" %}</button>
-                  ,
-                </form>
-              </span>
-            </li>
-          {% endfor %}
-        </ul>
-      </div>
-    </div>
-  </div>
-{% endif %}
-{% if sent_invitations %}
-  <div class="row mt-3">
-    <div class="col-md-12">
-      <div class="card">
-        <div class="card-header">{% trans "Assembly__invitation_sent__title" %}</div>
-        <ul class="list-group">
-          {% for invitation in sent_invitations %}
-            <li class="list-group-item">
-              {% if invitation.type == "HABITAT" and not assembly.is_cluster %}
-                {% blocktrans with habitat=invitation.requested.name %}Assembly__invitation_waiting__assembly_habitat {{habitat}}{% endblocktrans %}
-              {% elif invitation.type == "HABITAT" and assembly.is_cluster %}
-                {% blocktrans with assembly=invitation.requested.name %}Assembly__invitation_waiting__habitat_assembly {{assembly}}{% endblocktrans %}
-              {% else %}
-                {{ invitation.requester }}
-              {% endif %}
-              <span class="float-end">
-                <form method='post'
-                      action="{% url "backoffice:invitation" pk=invitation.pk %}">
-                  {% csrf_token %}
-                  <input type="hidden"
-                         name="next"
-                         value="{% url "backoffice:assembly" pk=assembly.pk %}">
-                  <a class="btn btn-primary btn-sm"
-                     href="{% url "backoffice:invitation" pk=invitation.pk %}">{% trans "Invitation__Habitat__action__edit" %}</a>
-                  <button type="submit"
-                          name="action"
-                          value="withdraw"
-                          class="btn btn-outline-secondary btn-sm">
-                    {% trans "Invitation__Habitat__action__withdraw" %}
-                  </button>
-                </form>
-              </span>
-            </li>
-          {% endfor %}
-        </ul>
-      </div>
-    </div>
-  </div>
-{% endif %}
diff --git a/src/backoffice/templates/backoffice/assembly_detail.html b/src/backoffice/templates/backoffice/assembly_detail.html
index 3e7357cb70da91697b85637c56aca7ac03d139f2..365e871f3afd948be7bc9074f4dffbb2bd3b64d1 100644
--- a/src/backoffice/templates/backoffice/assembly_detail.html
+++ b/src/backoffice/templates/backoffice/assembly_detail.html
@@ -1,6 +1,7 @@
 {% extends "backoffice/base.html" %}
 {% load static %}
 {% load i18n %}
+{% load rules %}
 
 {% block title %}
   {{ assembly.name }} | {{ conference.name }}
@@ -33,6 +34,7 @@ $(document).ready(() => {
   </script>
 {% endblock scripts %}
 {% block content %}
+  {% has_perm 'core.change_assembly' request.user assembly as can_change %}
   <div class="row">
     <div class="col-md-10">
       <div class="card border-primary">
@@ -191,7 +193,7 @@ $(document).ready(() => {
                 </button>
               </form>
             {% else %}
-              <a href="{% url "backoffice:invitation-send-with-pk" type="habitat" requester_id=assembly.pk %}"
+              <a href="{% url "backoffice:invitation-send" type="habitat" requester_id=assembly.pk %}"
                  class="btn btn-primary float-end">{% trans "assembly__parent__request" %}</a>
             {% endif %}
           </div>
@@ -201,5 +203,17 @@ $(document).ready(() => {
   {% else %}
     {% include "backoffice/assemblies/components/child_assemblies.html" %}
   {% endif %}
-  {% include "backoffice/assemblies/components/invitations.html" %}
+  {% if received_invitations %}
+    {% if assembly.received_invitations and can_change %}
+      {% trans "Invitation__list_pending__title" as list_title %}
+      {% include "backoffice/components/invitations_list.html" with invitations=received_invitations border_class="border-primary" list_title=list_title requested_view=True %}
+    {% endif %}
+  {% endif %}
+
+  {% if sent_invitations %}
+    {% if assembly.sent_invitations and can_change %}
+      {% trans "Invitation__list_sent__title" as list_title %}
+      {% include "backoffice/components/invitations_list.html" with invitations=sent_invitations border_class="border-secondary" list_title=list_title requester_view=True %}
+    {% endif %}
+  {% endif %}
 {% endblock content %}
diff --git a/src/backoffice/templates/backoffice/assembly_editchildren.html b/src/backoffice/templates/backoffice/assembly_editchildren.html
index f184d8711c120d3ece663c3201eb1ce72f4a9916..9f582aad9012e1e020efcd8a8330229c2871cfdb 100644
--- a/src/backoffice/templates/backoffice/assembly_editchildren.html
+++ b/src/backoffice/templates/backoffice/assembly_editchildren.html
@@ -1,9 +1,10 @@
 {% extends "backoffice/base.html" %}
 {% load i18n %}
 {% load static %}
-{% load widget_tweaks %}
+{% load rules %}
 
 {% block content %}
+  {% has_perm 'core.change_assembly' request.user assembly as can_change %}
   {% include "backoffice/assembly_edit_header.html" %}
 
   {% if form.errors %}
@@ -20,7 +21,7 @@
         <div class="card-header">{% trans "Assembly__edit_children__invite_assembly__title" %}</div>
         <div class="card-body">
           <p>{% trans "Assembly__edit_children__invite_assembly__introduction" %}</p>
-          <form action="{% url "backoffice:invitation-send-with-pk" type="habitat" requester_id=assembly.id %}"
+          <form action="{% url "backoffice:invitation-send" type="habitat" requester_id=assembly.id %}"
                 method="post">
             {% csrf_token %}
             <input type="hidden"
@@ -40,6 +41,19 @@
   </div>
 
   {% include "backoffice/assemblies/components/child_assemblies.html" %}
-  {% include "backoffice/assemblies/components/invitations.html" %}
+
+  {% if received_invitations %}
+    {% if assembly.received_invitations and can_change %}
+      {% trans "Invitation__list_pending__title" as list_title %}
+      {% include "backoffice/components/invitations_list.html" with invitations=received_invitations border_class="border-primary" list_title=list_title requested_view=True %}
+    {% endif %}
+  {% endif %}
+
+  {% if sent_invitations %}
+    {% if assembly.sent_invitations and can_change %}
+      {% trans "Invitation__list_sent__title" as list_title %}
+      {% include "backoffice/components/invitations_list.html" with invitations=sent_invitations border_class="border-secondary" list_title=list_title requester_view=True %}
+    {% endif %}
+  {% endif %}
 
 {% endblock content %}
diff --git a/src/backoffice/templates/backoffice/assembly_events.html b/src/backoffice/templates/backoffice/assembly_events.html
index 414c99697a20b43978d60388e648cdca33887903..0d2ef37c901d9b77f5c640220041b3c2d0cce52a 100644
--- a/src/backoffice/templates/backoffice/assembly_events.html
+++ b/src/backoffice/templates/backoffice/assembly_events.html
@@ -13,37 +13,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#events').DataTable({
-            pageLength: 100,
-            language: {
-                "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='events' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/assembly_list.html b/src/backoffice/templates/backoffice/assembly_list.html
index 2105ce2f2bc488f5e32b3d43fc86239092c4e0c4..09acf28e259cc139c9ca0f6c8b81a2a8ba5528c0 100644
--- a/src/backoffice/templates/backoffice/assembly_list.html
+++ b/src/backoffice/templates/backoffice/assembly_list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#assemblies').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='assemblies' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html b/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
index 06c8b523f80afebface6a12584dbaae35f7131c9..96eec278f4c0d9161ec0ff381d6b216136dff0e6 100644
--- a/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
+++ b/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
@@ -292,6 +292,6 @@
   </div>
 
   {% url "backoffice:assemblyteam-detail" pk=object.pk as add_comment_url %}
-  {% include "backoffice/activitylog_card.html" with filtered_logentry_changes_keys="last_update_assembly"|split:"," %}
+  {% include "backoffice/components/activity_log_card.html" with filtered_logentry_changes_keys="last_update_assembly"|split:"," %}
 
 {% endblock content %}
diff --git a/src/backoffice/templates/backoffice/base.html b/src/backoffice/templates/backoffice/base.html
index ac043b9518d8220c396d5a05acca341ff2ed31d4..0c38c59cec2034fe3b68d5425635cf95eb23e0fc 100644
--- a/src/backoffice/templates/backoffice/base.html
+++ b/src/backoffice/templates/backoffice/base.html
@@ -96,6 +96,12 @@
               </a>
             </li>
           {% endif %}
+          <li class="nav-item">
+            <a class="nav-link{% if active_page == 'teams' %} active{% endif %}"
+               href="{% url 'backoffice:teams' %}">{% trans "Teams" %}
+              {% if active_page == 'teams' %}<span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %}
+            </a>
+          </li>
           {% if has_conference_admin or has_assembly_registration_admin %}
             <li class="nav-item">
               <a class="nav-link{% if active_page == 'conference_admin' %} active{% endif %}"
@@ -120,6 +126,12 @@
               </a>
             </li>
           {% endif %}
+          <li class="nav-item">
+            <a class="nav-link{% if active_page == 'invitations' %} active{% endif %}"
+               href="{% url 'backoffice:invitations' %}">{% trans "Invitations" %}
+              {% if active_page == 'invitations' %}<span class="visually-hidden">{{ activetab_srmarker }}</span>{% endif %}
+            </a>
+          </li>
         </ul>
         <ul class="navbar-nav">
           {% if conferences|length > 1 or active_page == 'conferences' %}
diff --git a/src/backoffice/templates/backoffice/components/activity_log_card.html b/src/backoffice/templates/backoffice/components/activity_log_card.html
new file mode 100644
index 0000000000000000000000000000000000000000..6c7ff6f4774c3aabdef01b6acca25cbddc4429e7
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/activity_log_card.html
@@ -0,0 +1,156 @@
+{% load c3assemblies %}
+{% load i18n %}
+{% load humanize %}
+
+{% gen_rand_str as alc_ident %}
+{% if all_log_entries or msg_log_entries %}
+  <div class="row mb-3">
+    <div class="col-md-12">
+      <div class="card" id="logentries_{{ alc_ident }}">
+        <div class="card-header d-flex p-0 pt-2 border-bottom-0">
+          <div class="ps-3 flex-grow-1 border-bottom border-bottom-1">
+            <span class="align-middle">{% trans "ActivityLogEntries" %}</span>
+          </div>
+          <div class="d-flex float-end">
+            <div role="ms-2 navigation justify-content-end">
+              <ul class="nav nav-tabs" id="log-message-tabs" role="tablist">
+                <li class="nav-item" role="presentation">
+                  <button class="nav-link {% if not msg_log_entries %}active{% endif %}"
+                          id="all-tab"
+                          data-bs-toggle="tab"
+                          data-bs-target="#all-logs"
+                          type="button"
+                          role="tab"
+                          aria-controls="all-logs"
+                          aria-selected="{% if msg_log_entries %}false{% else %}true{% endif %}">
+                    {% trans "ActivityLog__visible_entries__all" %}
+                  </button>
+                </li>
+                <li class="nav-item" role="presentation">
+                  <button class="nav-link {% if not msg_log_entries %}disabled{% else %}active{% endif %}"
+                          id="msg-tab"
+                          data-bs-toggle="tab"
+                          data-bs-target="#msg-logs"
+                          type="button"
+                          role="tab"
+                          aria-controls="msg-logs"
+                          {% if msg_log_entries %}aria-disabled="true"{% endif %}
+                          aria-selected="{% if msg_log_entries %}false{% else %}true{% endif %}">
+                    {% trans "ActivityLog__visible_entries__msg" %}
+                  </button>
+                </li>
+              </ul>
+            </div>
+            {% if add_comment_url %}
+              <div class="ps-3 pe-3 border-bottom border-bottom-1">
+                <button class="btn btn-sm btn-primary"
+                        title="{% trans "ActivityLog_add_comment" %}"
+                        data-bs-toggle="modal"
+                        data-bs-target="#addCommentModal_{{ alc_ident }}">
+                  <i class="bi bi-chat-left-text"></i>
+                </button>
+              </div>
+            {% else %}
+              <div class="pe-3 border-bottom border-bottom-1"></div>
+            {% endif %}
+          </div>
+
+        </div>
+        <div class="card-body">
+          <div class="tab-content">
+            <div class="tab-pane {% if not msg_log_entries %}active{% endif %}"
+                 id="all-logs"
+                 role="tabpanel"
+                 aria-labelledby="all-tab">
+              {% for entry in all_log_entries %}
+                {% include "backoffice/components/activity_log_entry.html" %}
+              {% endfor %}
+            </div>
+            <div class="tab-pane {% if msg_log_entries %}active{% endif %}"
+                 id="msg-logs"
+                 role="tabpanel"
+                 aria-labelledby="msg-tab">
+              {% for entry in msg_log_entries %}
+                {% include "backoffice/components/activity_log_entry.html" %}
+              {% endfor %}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  {% if add_comment_url %}
+    <div class="modal fade"
+         id="addCommentModal_{{ alc_ident }}"
+         tabindex="-1"
+         aria-labelledby="addCommentModalLabel"
+         aria-hidden="true">
+      <div class="modal-dialog">
+        <div class="modal-content">
+          <div class="modal-header">
+            <h1 class="modal-title fs-5" id="addCommentModalLabel">{% trans "ActivityLog_add_comment" %}</h1>
+            <button type="button"
+                    class="btn-close"
+                    data-bs-dismiss="modal"
+                    aria-label="Close"></button>
+          </div>
+          <div class="modal-body">
+            <p>{{ add_comment_help }}</p>
+            <form id="add_comment_{{ alc_ident }}"
+                  action="{{ add_comment_url }}"
+                  method="post">
+              {% csrf_token %}
+              <textarea name="comment" class="form-control" placeholder="" required></textarea>
+            </form>
+          </div>
+          <div class="modal-footer">
+            <button class="btn btn-primary" form="add_comment_{{ alc_ident }}">
+              <i class="bi bi-chat-left-text"></i> {% trans "ActivityLog_add_comment_do" %}
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  {% endif %}
+
+  <!-- Show all messages and hide the tabs when JS is disabled -->
+  <noscript>
+    <style>
+    #msg-logs {
+      display: none;
+    }
+    #log-message-tabs {
+      display: none;
+    }
+    #all-logs {
+      display: block;
+    }
+    </style>
+  </noscript>
+{% else %}
+  <div class="row mb-3">
+    <div class="col-md-12">
+      <div class="card" id="logentries_{{ alc_ident }}">
+        <div class="card-header d-flex">
+          <div class="flex-grow-1">
+            <span>{% trans "ActivityLogEntries" %}</span>
+          </div>
+          {% if add_comment_url %}
+            <div class="ps-3 pe-3 border-bottom border-bottom-1">
+              <button class="btn btn-sm btn-primary"
+                      title="{% trans "ActivityLog_add_comment" %}"
+                      data-bs-toggle="modal"
+                      data-bs-target="#addCommentModal_{{ alc_ident }}">
+                <i class="bi bi-chat-left-text"></i>
+              </button>
+            </div>
+          {% endif %}
+        </div>
+        <div class="card-body">
+          <div role="alert">{% trans "ActivityLog_no_entries" %}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+{% endif %}
diff --git a/src/backoffice/templates/backoffice/components/activity_log_entry.html b/src/backoffice/templates/backoffice/components/activity_log_entry.html
new file mode 100644
index 0000000000000000000000000000000000000000..63f9b02582144ad14afcad85b70595348780c47f
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/activity_log_entry.html
@@ -0,0 +1,33 @@
+{% load humanize %}
+
+<div class="mb-3 logentry {% if entry.kind == entry.Kind.SYSTEM %}text-muted{% endif %}"
+     data-has-msg="{% if entry.comment %}y{% else %}n{% endif %}">
+  {% if latest_note and latest_note.pk == entry.pk %}<a id="latest_note"></a>{% endif %}
+  <div class="border-bottom border-secondary mb-1 {% if not entry.comment %}text-muted{% endif %}">
+    <abbr title="{{ entry.timestamp }}">{{ entry.timestamp|naturaltime }}</abbr>
+    <span class="mx-1 badge bg-light text-bg-light border border-secondary-subtle"><i class="bi bi-person"></i> {{ entry.user.username }}</span>
+    <span class="text-muted">{{ entry.get_kind_display }}</span>
+  </div>
+  {% if entry.changes %}
+    <i class="bi bi-table float-start"></i>
+    <dl class="ms-4 mb-1 row small text-muted">
+      {% for k, v in entry.changes.items %}
+        {% if k not in filtered_logentry_changes_keys %}
+          <dt class="col-md-3">{{ k }}:</dt>
+          <dd class="col-md-9">
+            {% if v|is_dict %}
+              <s class="fst-italic">{{ v.old }}</s>
+              {{ v.new }}
+            {% else %}
+              {{ v }}
+            {% endif %}
+          </dd>
+        {% endif %}
+      {% endfor %}
+    </dl>
+  {% endif %}
+  {% if entry.comment %}
+    <i class="bi bi-chat-left-text float-start"></i>
+    <div class="ms-4 p-1 border border-2 border-info rounded">{{ entry.comment }}</div>
+  {% endif %}
+</div>
diff --git a/src/backoffice/templates/backoffice/components/confirmation_modal.html b/src/backoffice/templates/backoffice/components/confirmation_modal.html
new file mode 100644
index 0000000000000000000000000000000000000000..28a1098be1df309e6f8eb9fbe3275ef28c233761
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/confirmation_modal.html
@@ -0,0 +1,25 @@
+{% extends "backoffice/base.html" %}
+{% load rules %}
+{% load i18n %}
+{% load static %}
+{% load hub_absolute %}
+
+{% block title %}
+  {{ confirmation_title }}
+{% endblock title %}
+{% block content %}
+  <div class="card">
+    <div class="card-header text-bg-{{ confirmation_class }}"
+         id="confirmationModalHeader">
+      <h1 class="fs-5" id="confirmationModalLabel">{{ confirmation_title }}</h1>
+    </div>
+    <div class="card-body" id="confirmationModalBody">{{ confirmation_body }}</div>
+    <form action="?confirmation=true" method="post">
+      <div class="card-footer text-end">
+        <a href="{{ confirmation_cancel_url }}" class="btn btn-secondary">{% trans "Cancel" %}</a>
+        {% csrf_token %}
+        <button type="submit" class="btn btn-{{ confirmation_class }}">{{ confirmation_submit }}</button>
+      </div>
+    </form>
+  </div>
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/components/invitations_list.html b/src/backoffice/templates/backoffice/components/invitations_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..89b74e928c3a35810b2d0438cd8519eea44466c2
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/invitations_list.html
@@ -0,0 +1,49 @@
+{% load rules %}
+{% load i18n %}
+
+<div class="card {{ border_class }} mt-3">
+  <div class="card-header">{{ list_title }}</div>
+  <ul class="list-group">
+    {% with requester_view=requester_view|default:False requested_view=requested_view|default:False %}
+      {% for invitation in invitations %}
+        {% has_perm 'core.change_invitation' request.user invitation as can_change %}
+        {% has_perm 'core.accept_invitation' request.user invitation as can_accept %}
+        {% if can_change or can_accept %}
+          <li class="list-group-item">
+            {% blocktrans with requester=invitation.requester.name requested=invitation.requested.name type=invitation.get_type_display  %}Invitation__description {{ requester }} {{ requested }} {{ type }}{% endblocktrans %}
+            <span class="float-end">
+              <form method='post'
+                    action="{% url "backoffice:invitation-decision" pk=invitation.pk %}">
+                {% csrf_token %}
+                <input type="hidden" name="next" value="{{ request.path }}">
+
+                {% if can_change and requester_view %}
+                  <a class="btn btn-primary btn-sm"
+                     href="{% url "backoffice:invitation-edit" pk=invitation.pk %}">{% trans "Invitation__action__edit" %}</a>
+                  <button type="submit"
+                          name="action"
+                          value="withdraw"
+                          class="btn btn-outline-secondary btn-sm">{% trans "Invitation__action__withdraw" %}</button>
+                {% else %}
+                  <a type="submit"
+                     class="btn btn-primary btn-sm"
+                     href="{% url "backoffice:invitation-detail" pk=invitation.pk %}">{% trans "Invitation__action__detail" %}</a>
+                {% endif %}
+                {% if can_accept and requested_view %}
+                  <button type="submit"
+                          name="action"
+                          value="accept"
+                          class="btn btn-success btn-sm">{% trans "Invitation__action__accept" %}</button>
+                  <button type="submit"
+                          name="action"
+                          value="reject"
+                          class="btn btn-danger btn-sm">{% trans "Invitation__action__reject" %}</button>
+                {% endif %}
+              </form>
+            </span>
+          </li>
+        {% endif %}
+      {% endfor %}
+    {% endwith %}
+  </ul>
+</div>
diff --git a/src/backoffice/templates/backoffice/components/list_script.js b/src/backoffice/templates/backoffice/components/list_script.js
new file mode 100644
index 0000000000000000000000000000000000000000..5373e510cd27d51c37581c688a4ef35589f65482
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/list_script.js
@@ -0,0 +1,30 @@
+{% load i18n %}
+$(document).ready(function() {
+  $('#{{ table_id }}').DataTable({
+    pageLength: 100,
+    language: {
+      "decimal":        "",
+        "emptyTable":     "{% trans "data_table__info__no_data" %}",
+        "info":           "{% trans "data_table__info__paginated" %}",
+        "infoEmpty":      "{% trans "data_table__info__empty" %}",
+        "infoFiltered":   "{% trans "data_table__info__filtered" %}",
+        "infoPostFix":    "",
+        "thousands":      " ",
+        "lengthMenu":     "{% trans "data_table__paginate__menu" %}",
+        "loadingRecords": "{% trans "data_table__loading" %}",
+        "processing":     "{% trans "data_table__processing" %}",
+        "search":         "{% trans "data_table_search" %}",
+        "zeroRecords":    "{% trans "data_table__no_entries" %}",
+        "paginate": {
+            "first":      "{% trans "data_table__paginate__first" %}",
+            "last":       "{% trans "data_table__paginate__last" %}",
+            "next":       "{% trans "data_table__paginate__next" %}",
+            "previous":   "{% trans "data_table__paginate__previous" %}"
+        },
+        "aria": {
+            "sortAscending":  ": {% trans "data_table__aria__sort_ascending" %}", {#activate to sort column ascending#}
+            "sortDescending":  ": {% trans "data_table__aria__sort_descending" %}", {#activate to sort column descending#}
+        }
+    }
+  });
+});
diff --git a/src/backoffice/templates/backoffice/event/components/list_script.js b/src/backoffice/templates/backoffice/event/components/list_script.js
deleted file mode 100644
index 6fadf5dc7f6ed2a1b0072a475ffabca2e9b20e4c..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/event/components/list_script.js
+++ /dev/null
@@ -1,30 +0,0 @@
-{% load i18n %}
-$(document).ready(function() {
-  $('#events').DataTable({
-    pageLength: 100,
-    language: {
-      "decimal":        "",
-        "emptyTable":     "{% trans "assembly_info_no_data" %}",
-        "info":           "{% trans "assembly_info_paginated" %}",
-        "infoEmpty":      "{% trans "assembly_info_empty" %}",
-        "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-        "infoPostFix":    "",
-        "thousands":      " ",
-        "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-        "loadingRecords": "LOADING ...",
-        "processing":     "Processing...",
-        "search":         "{% trans "assembly_search" %}",
-        "zeroRecords":    "{% trans "assembly_no_entries" %}",
-        "paginate": {
-            "first":      "{% trans "assembly_paginate_first" %}",
-            "last":       "{% trans "assembly_paginate_last" %}",
-            "next":       "{% trans "assembly_paginate_next" %}",
-            "previous":   "{% trans "assembly_paginate_previous" %}"
-        },
-        "aria": {
-            "sortAscending":  ": activate to sort column ascending",
-            "sortDescending": ": activate to sort column descending"
-        }
-    }
-  });
-});
diff --git a/src/backoffice/templates/backoffice/event/list.html b/src/backoffice/templates/backoffice/event/list.html
index 14df4bad69f24ae365fffc5f8d2ad3f9b503e1ae..1a2021a8f8ff967dd03c88688f58687386b71ffc 100644
--- a/src/backoffice/templates/backoffice/event/list.html
+++ b/src/backoffice/templates/backoffice/event/list.html
@@ -10,7 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/event/components/list_script.js" %}</script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='events' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html b/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html
deleted file mode 100644
index ba7ab041955203c8506e2d7d2df0092ecefb0d22..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/invitations/components/create_edit_form.html
+++ /dev/null
@@ -1,160 +0,0 @@
-
-{% load i18n %}
-{% load django_bootstrap5 %}
-{% load verbose_name %}
-<form method="post">
-  {% csrf_token %}
-  <div class="card">
-    <div class="card-header">
-      {{ title_text }}
-      {% if form.instance.state == "requested" %}
-        <span class="badge float-end bg-secondary">requested</span>
-      {% elif form.instance.state == "accepted" %}
-        <span class="badge float-end bg-success">accepted</span>
-      {% elif form.instance.state == "rejected" %}
-        <span class="badge float-end bg-danger">rejected</span>
-      {% elif form.instance.state == "withdrawn" %}
-        <span class="badge float-end bg-warning">withdrawn</span>
-      {% endif %}
-    </div>
-    <div class="card-body">
-      <div class="row">
-        <div class="col-md-12">
-          <p>{{ introduction_text }}</p>
-        </div>
-      </div>
-      {% if form.non_field_errors %}
-        <div class="row">
-          <div class="col-md-12">
-            <div class="alert border-danger">
-              <h4 class="alert-heading">Errors</h4>
-              <ul class="list-group">
-                {% for error in form.non_field_errors %}
-                  <li class="list-group-item list-group-item-danger">{{ error }}</li>
-                {% endfor %}
-              </ul>
-            </div>
-          </div>
-        </div>
-      {% endif %}
-      {% if form.create %}
-        <div class="row">
-          <div class="col-md-6">{% bootstrap_field form.requester_id %}</div>
-          <div class="col-md-6">{% bootstrap_field form.requested_id %}</div>
-        </div>
-        <div class="row">
-          <div class="col-md-12">{% bootstrap_field form.comment %}</div>
-        </div>
-      {% else %}
-        <div class="row">
-          <label for="requester" class="col-md-1 col-form-label">{{ form.requester_label }}</label>
-          <div class="col-md-5">
-            {% if form.permissions.requester %}
-              <a id="requester"
-                 class="form-control-plaintext"
-                 href="{% hub_absolute "backoffice:assembly" pk=form.instance.requester.pk %}">{{ form.instance.requester.name }}</a>
-            {% else %}
-              <span id="requester" class="form-control-plaintext">{{ form.instance.requester.name }}</span>
-            {% endif %}
-            <input type="hidden"
-                   name="requester_id"
-                   value="{{ form.instance.requester.pk }}">
-          </div>
-          <label for="requester" class="col-md-1 col-form-label">{{ form.requested_label }}</label>
-          <div class="col-md-5">
-            {% if form.permissions.requested %}
-              <a id="requested"
-                 class="form-control-plaintext"
-                 href="{% hub_absolute "backoffice:assembly" pk=form.instance.requested.pk %}">{{ form.instance.requested.name }}</a>
-            {% else %}
-              <span id="requested" class="form-control-plaintext">{{ form.instance.requested.name }}</span>
-            {% endif %}
-            <input type="hidden"
-                   name="requested_id"
-                   value="{{ form.instance.requested.pk }}">
-          </div>
-        </div>
-        <div class="row">
-          <label for="requester" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"requested_at" }}</label>
-          <div class="col-md-5">
-            <input type="text"
-                   name="requester"
-                   id="requester"
-                   class="form-control-plaintext"
-                   value="{{ form.instance.requested_at }}">
-          </div>
-          <label for="requested" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"updated_at" }}</label>
-          <div class="col-md-5">
-            <input type="text"
-                   name="requested"
-                   id="requested"
-                   class="form-control-plaintext"
-                   value="{{ form.instance.updated_at }}">
-          </div>
-        </div>
-        <div class="row">
-          <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label>
-          <div class="col-md-4">
-            <input type="text"
-                   id="sender"
-                   class="form-control-plaintext"
-                   value="{{ invitation.sender }}">
-          </div>
-          {% if invitation.decision_by %}
-            <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label>
-            <div class="col-md-4">
-              <input type="text"
-                     id="decision_by"
-                     class="form-control-plaintext"
-                     value="{{ invitation.decision_by }}">
-            </div>
-          {% endif %}
-        </div>
-        {% if form.comment %}
-          <div class="row">
-            <div class="col-md-12">{% bootstrap_field form.comment %}</div>
-          </div>
-        {% else %}
-          <div class="row">
-            {% if invitation.comment %}
-              <div class="col-md-12">
-                <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label>
-                <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea>
-              </div>
-            {% else %}
-              <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label>
-              <div class="col-md-10">
-                <input type="text"
-                       name="requested"
-                       id="requested"
-                       class="form-control-plaintext"
-                       value="No comment">
-              </div>
-            {% endif %}
-          </div>
-        {% endif %}
-      {% endif %}
-    </div>
-    {% if form.create or form.instance.state == 'requested' %}
-      <div class="card-footer">
-        <div class="float-end">
-          {% if form.create %}
-            <button type="submit" class="btn btn-primary">{{ submit_text }}</button>
-          {% else %}
-            {% if form.permissions.requester %}
-              <button type="submit" name="action" value="update" class="btn btn-primary">{{ update_text }}</button>
-              <button type="submit"
-                      name="action"
-                      value="withdraw"
-                      class="btn btn-outline-secondary">{{ withdraw_text }}</button>
-            {% endif %}
-            {% if form.permissions.requested %}
-              <button type="submit" name="action" value="accept" class="btn btn-primary">{{ accept_text }}</button>
-              <button type="submit" name="action" value="reject" class="btn btn-danger">{{ reject_text }}</button>
-            {% endif %}
-          </div>
-        {% endif %}
-      </div>
-    {% endif %}
-  </div>
-</form>
diff --git a/src/backoffice/templates/backoffice/invitations/create_edit.html b/src/backoffice/templates/backoffice/invitations/create_edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..0094d266751c5f538e0b2000c620d1f679868961
--- /dev/null
+++ b/src/backoffice/templates/backoffice/invitations/create_edit.html
@@ -0,0 +1,203 @@
+{% extends "backoffice/base.html" %}
+{% load django_bootstrap5 %}
+{% load i18n %}
+{% load verbose_name %}
+
+{% block title %}
+  {{ invitation__type }} | {{ conference.name }}
+{% endblock title %}
+
+{% block content %}
+  {% include "backoffice/assembly_edit_header.html" %}
+
+  <form method="post">
+    {% csrf_token %}
+    <div class="card">
+      <div class="card-header">
+        {% if form.create %}
+          {% trans "Create" %} {{ invitation__type }}
+        {% else %}
+          {% trans "Edit" %} {{ invitation__type }}
+        {% endif %}
+        {% if form.instance.state == "requested" %}
+          <span class="badge float-end bg-secondary">requested</span>
+        {% elif form.instance.state == "accepted" %}
+          <span class="badge float-end bg-success">accepted</span>
+        {% elif form.instance.state == "rejected" %}
+          <span class="badge float-end bg-danger">rejected</span>
+        {% elif form.instance.state == "withdrawn" %}
+          <span class="badge float-end bg-warning">withdrawn</span>
+        {% endif %}
+      </div>
+      <div class="card-body">
+        <div class="row">
+          <div class="col-md-12">
+            <p>{{ introduction_text }}</p>
+          </div>
+        </div>
+        {% if form.non_field_errors %}
+          <div class="row">
+            <div class="col-md-12">
+              <div class="alert border-danger">
+                <h4 class="alert-heading">Errors</h4>
+                <ul class="list-group">
+                  {% for error in form.non_field_errors %}
+                    <li class="list-group-item list-group-item-danger">{{ error }}</li>
+                  {% endfor %}
+                </ul>
+              </div>
+            </div>
+          </div>
+        {% endif %}
+        {% if form.create %}
+          <div class="row">
+            <div class="col-md-6">
+              {% if form.requester_label %}
+                <label class="form-label" for="requester_text">{{ form.requester_label }}</label>
+              {% endif %}
+              {% if form.requester_text %}
+                <input id="requester_text"
+                       class="form-control-plaintext"
+                       value="{{ form.requester_text }}">
+              {% endif %}
+              {% bootstrap_field form.requester_id %}
+
+            </div>
+            <div class="col-md-6">
+              {% if form.requested_label %}
+                <label class="form-label" for="requested_text">{{ form.requested_label }}</label>
+              {% endif %}
+              {% if form.requester_text %}
+                <input id="requested_text"
+                       class="form-control-plaintext"
+                       value="{{ form.requested_text }}">
+              {% endif %}
+              {% bootstrap_field form.requested_id %}
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">{% bootstrap_field form.comment %}</div>
+          </div>
+        {% else %}
+          <div class="row">
+            <label for="requester" class="col-md-2 col-form-label">{{ form.fields.requester_id.label }}</label>
+            <div class="col-md-4">
+              {% if form.requester_link %}
+                <a id="requester"
+                   class="form-control-plaintext"
+                   href="{{ form.requester_link }}"
+                   target="_blank">{{ form.instance.requester.name }}</a>
+              {% else %}
+                <span id="requester" class="form-control-plaintext">{{ form.instance.requester.name }}</span>
+              {% endif %}
+              <input type="hidden"
+                     name="requester_id"
+                     value="{{ form.instance.requester.pk }}">
+            </div>
+            <label for="requester" class="col-md-2 col-form-label">{{ form.fields.requested_id.label }}</label>
+            <div class="col-md-4">
+              {% if form.requested_link %}
+                <a id="requested"
+                   class="form-control-plaintext"
+                   href="{{ form.requested_link }}"
+                   target="_blank">{{ form.instance.requested.name }}</a>
+              {% else %}
+                <span id="requested" class="form-control-plaintext">{{ form.instance.requested.name }}</span>
+              {% endif %}
+              <input type="hidden"
+                     name="requested_id"
+                     value="{{ form.instance.requested.pk }}">
+            </div>
+          </div>
+          <div class="row">
+            <label for="requester" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"requested_at" }}</label>
+            <div class="col-md-5">
+              <input type="text"
+                     name="requester"
+                     id="requester"
+                     class="form-control-plaintext"
+                     value="{{ form.instance.requested_at }}">
+            </div>
+            <label for="requested" class="col-md-1 col-form-label">{{ form.instance|verbose_name:"updated_at" }}</label>
+            <div class="col-md-5">
+              <input type="text"
+                     name="requested"
+                     id="requested"
+                     class="form-control-plaintext"
+                     value="{{ form.instance.updated_at }}">
+            </div>
+          </div>
+          <div class="row">
+            <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label>
+            <div class="col-md-4">
+              <input type="text"
+                     id="sender"
+                     class="form-control-plaintext"
+                     value="{{ invitation.sender }}">
+            </div>
+            {% if invitation.decision_by %}
+              <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label>
+              <div class="col-md-4">
+                <input type="text"
+                       id="decision_by"
+                       class="form-control-plaintext"
+                       value="{{ invitation.decision_by }}">
+              </div>
+            {% endif %}
+          </div>
+          {% if form.comment %}
+            <div class="row">
+              <div class="col-md-12">{% bootstrap_field form.comment %}</div>
+            </div>
+          {% else %}
+            <div class="row">
+              {% if invitation.comment %}
+                <div class="col-md-12">
+                  <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label>
+                  <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea>
+                </div>
+              {% else %}
+                <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label>
+                <div class="col-md-10">
+                  <input type="text"
+                         name="requested"
+                         id="requested"
+                         class="form-control-plaintext"
+                         value="No comment">
+                </div>
+              {% endif %}
+            </div>
+          {% endif %}
+        {% endif %}
+      </div>
+      {% if form.create or form.instance.state == 'requested' %}
+        <div class="card-footer">
+          <div class="float-end">
+            {% if form.create %}
+              <button type="submit" class="btn btn-primary">{% trans "Invitation__action__submit" %}</button>
+            {% else %}
+              {% if form.permissions.requester %}
+                <button type="submit" name="action" value="update" class="btn btn-primary">
+                  {% trans "Invitation__action__update" %}
+                </button>
+                <button type="submit"
+                        name="action"
+                        value="withdraw"
+                        class="btn btn-outline-secondary">{% trans "Invitation__action__withdraw" %}</button>
+              {% endif %}
+              {% if form.permissions.requested %}
+                <button type="submit" name="action" value="accept" class="btn btn-primary">
+                  {% trans "Invitation__action__accept" %}
+                </button>
+                <button type="submit" name="action" value="reject" class="btn btn-danger">
+                  {% trans "Invitation__action__reject" %}
+                </button>
+              {% endif %}
+            </div>
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+  </form>
+
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html b/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html
deleted file mode 100644
index f7081ce6350a0c3056a6af9ffa6cb6ee8f80d1cb..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/invitations/create_edit_habitat.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{% extends "backoffice/base.html" %}
-{% load i18n %}
-{% load django_bootstrap5 %}
-
-{% block title %}
-  {% trans "Invitation__Habitat__type" %} | {{ conference.name }}
-{% endblock title %}
-
-{% block content %}
-  {% include "backoffice/assembly_edit_header.html" %}
-
-  {% trans "Invitation__Habitat__title" as title_text %}
-  {% trans "Invitation__Habitat__introduction" as introduction_text %}
-  {% trans "Invitation__Habitat__action__submit" as submit_text %}
-  {% trans "Invitation__Habitat__action__update" as update_text %}
-  {% trans "Invitation__Habitat__action__withdraw" as withdraw_text %}
-  {% trans "Invitation__Habitat__action__accept" as accept_text %}
-  {% trans "Invitation__Habitat__action__reject" as reject_text %}
-  {% include "backoffice/invitations/components/create_edit_form.html" with title_text=title_text introduction_text=introduction_text edit_text=edit_text withdraw_text=withdraw_text accept_text=accept_text reject_text=reject_text %}
-
-{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/invitations/detail.html b/src/backoffice/templates/backoffice/invitations/detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..c1949ec423bcce6634100c3d754240a6d555afb1
--- /dev/null
+++ b/src/backoffice/templates/backoffice/invitations/detail.html
@@ -0,0 +1,157 @@
+{% extends "backoffice/base.html" %}
+{% load i18n %}
+{% load django_bootstrap5 %}
+{% load rules %}
+{% load verbose_name %}
+
+{% block title %}
+  {{ title_text }} | {{ invitation.conference.name }}
+{% endblock title %}
+
+{% block content %}
+  {% include "backoffice/assembly_edit_header.html" %}
+  {% has_perm "core.accept_invitation" request.user invitation as can_accept %}
+  {% has_perm "core.change_invitation" request.user invitation as can_modify %}
+
+  <form method="post"
+        action="{% url "backoffice:invitation-decision" pk=invitation.pk %}">
+    {% csrf_token %}
+    <div class="card">
+      <div class="card-header">
+        {{ title_text }}
+        {% if invitation.state == "requested" %}
+          <span class="badge float-end bg-secondary">requested</span>
+        {% elif invitation.state == "accepted" %}
+          <span class="badge float-end bg-success">accepted</span>
+        {% elif invitation.state == "rejected" %}
+          <span class="badge float-end bg-danger">rejected</span>
+        {% elif invitation.state == "withdrawn" %}
+          <span class="badge float-end bg-warning">withdrawn</span>
+        {% endif %}
+      </div>
+      <div class="card-body">
+        {% if invitation.state == "requested" %}
+          <div class="row">
+            <div class="col-md-12">
+              <p>{{ introduction_text }}</p>
+            </div>
+          </div>
+        {% endif %}
+
+        <div class="row">
+          <label for="requester" class="col-md-2 col-form-label">{{ requester_label }}</label>
+          <div class="col-md-4">
+            {% if requester_link %}
+              <a id="requester"
+                 class="form-control-plaintext"
+                 href="{{ requester_link }}"
+                 target="_blank">{{ invitation.requester.name }}</a>
+            {% else %}
+              <span id="requester" class="form-control-plaintext">{{ invitation.requester.name }}</span>
+            {% endif %}
+            <input type="hidden"
+                   name="requester_id"
+                   value="{{ invitation.requester.pk }}">
+          </div>
+          <label for="requester" class="col-md-2 col-form-label">{{ requested_label }}</label>
+          <div class="col-md-4">
+            {% if requested_link %}
+              <a id="requested"
+                 class="form-control-plaintext"
+                 href="{{ requested_link }}"
+                 target="_blank">{{ invitation.requested.name }}</a>
+            {% else %}
+              <span id="requested" class="form-control-plaintext">{{ invitation.requested.name }}</span>
+            {% endif %}
+            <input type="hidden"
+                   name="requested_id"
+                   value="{{ invitation.requested.pk }}">
+          </div>
+        </div>
+        <div class="row">
+          <label for="requester" class="col-md-2 col-form-label">{{ invitation|verbose_name:"requested_at" }}</label>
+          <div class="col-md-4">
+            <input type="text"
+                   name="requester"
+                   id="requester"
+                   class="form-control-plaintext"
+                   value="{{ invitation.requested_at }}">
+          </div>
+          <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"updated_at" }}</label>
+          <div class="col-md-4">
+            <input type="text"
+                   name="requested"
+                   id="requested"
+                   class="form-control-plaintext"
+                   value="{{ invitation.updated_at }}">
+          </div>
+        </div>
+        <div class="row">
+          <label for="sender" class="col-md-2 col-form-label">{{ invitation|verbose_name:"sender" }}</label>
+          <div class="col-md-4">
+            <input type="text"
+                   id="sender"
+                   class="form-control-plaintext"
+                   value="{{ invitation.sender }}">
+          </div>
+          {% if invitation.decision_by %}
+            <label for="decision_by" class="col-md-2 col-form-label">{{ invitation|verbose_name:"decision_by" }}</label>
+            <div class="col-md-4">
+              <input type="text"
+                     id="decision_by"
+                     class="form-control-plaintext"
+                     value="{{ invitation.decision_by }}">
+            </div>
+          {% endif %}
+        </div>
+        {% if form.comment %}
+          <div class="row">
+            <div class="col-md-12">{% bootstrap_field form.comment %}</div>
+          </div>
+        {% else %}
+          <div class="row">
+            {% if invitation.comment %}
+              <div class="col-md-12">
+                <label for="comment" class="form-label">{{ invitation|verbose_name:"comment" }}</label>
+                <textarea name="comment" id="comment" class="form-control-plaintext" rows="3">{{ invitation.comment }}</textarea>
+              </div>
+            {% else %}
+              <label for="requested" class="col-md-2 col-form-label">{{ invitation|verbose_name:"comment" }}</label>
+              <div class="col-md-10">
+                <input type="text"
+                       name="requested"
+                       id="requested"
+                       class="form-control-plaintext"
+                       value="{% trans "No comment" %}">
+              </div>
+            {% endif %}
+          </div>
+        {% endif %}
+
+      </div>
+      {% if invitation.state == "requested" %}
+        <div class="card-footer">
+          <div class="float-end">
+            {% if can_modify %}
+              <a class="btn btn-primary"
+                 href="{% url "backoffice:invitation-edit" pk=invitation.pk %}">{% trans "Invitation__action__edit" %}</a>
+              <button type="submit"
+                      name="action"
+                      value="withdraw"
+                      class="btn btn-outline-secondary">{% trans "Invitation__action__withdraw" %}</button>
+            {% endif %}
+            {% if can_accept %}
+              <button type="submit" name="action" value="accept" class="btn btn-primary">
+                {% trans "Invitation__action__accept" %}
+              </button>
+              <button type="submit" name="action" value="reject" class="btn btn-danger">
+                {% trans "Invitation__action__reject" %}
+              </button>
+            {% endif %}
+          </div>
+        </div>
+      {% endif %}
+    </div>
+  </form>
+
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/invitations/list.html b/src/backoffice/templates/backoffice/invitations/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..cdc6a601d421f76145bed09b5f65de6b77821367
--- /dev/null
+++ b/src/backoffice/templates/backoffice/invitations/list.html
@@ -0,0 +1,16 @@
+{% extends "backoffice/base.html" %}
+{% load i18n %}
+{% load django_bootstrap5 %}
+{% load rules %}
+
+{% block title %}
+  {{ title_text }} | {{ invitation.conference.name }}
+{% endblock title %}
+
+{% block content %}
+  {% include "backoffice/assembly_edit_header.html" %}
+
+  {% trans "Invitations__list__title" as list_title %}
+  {% include "backoffice/components/invitations_list.html" with invitations=invitations list_title=list_title requester_view=True requested_view=True %}
+
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/map_floor_list.html b/src/backoffice/templates/backoffice/map_floor_list.html
index b487154244e033c3d7d4277b8b83e8d88784aa21..145670858fb77d7736b3cef4315789f3e96ea837 100644
--- a/src/backoffice/templates/backoffice/map_floor_list.html
+++ b/src/backoffice/templates/backoffice/map_floor_list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#pois').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='pois' %}</script>
 {% endblock title %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/map_poi_list.html b/src/backoffice/templates/backoffice/map_poi_list.html
index 9fb3218ae38ce3a84ed82490a974c9462d8cb9b6..b69330cfbe8bc19ab0fac944d60308a3120509d3 100644
--- a/src/backoffice/templates/backoffice/map_poi_list.html
+++ b/src/backoffice/templates/backoffice/map_poi_list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#pois').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='pois' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/moderation_assembly-list.html b/src/backoffice/templates/backoffice/moderation_assembly-list.html
index 42cbfceea7da652dd472f4842c16089122debaa8..a49612a1a1e939d46dfdc6d7f9b24b48058d759b 100644
--- a/src/backoffice/templates/backoffice/moderation_assembly-list.html
+++ b/src/backoffice/templates/backoffice/moderation_assembly-list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#assemblies').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='assemblies' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/moderation_badge-list.html b/src/backoffice/templates/backoffice/moderation_badge-list.html
index ee5f65b8e1fc71a46bba2d5b1c79f61b2f51e0c4..4203f50fb51ed5d0cea5f50436dc3f33312c54b6 100644
--- a/src/backoffice/templates/backoffice/moderation_badge-list.html
+++ b/src/backoffice/templates/backoffice/moderation_badge-list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#badges').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='badges' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/moderation_board-list.html b/src/backoffice/templates/backoffice/moderation_board-list.html
index 4ec666f2a065baeff5fec851fde2174912797fe8..b24f357df53a36fd0ea32a8726232eca41176e5e 100644
--- a/src/backoffice/templates/backoffice/moderation_board-list.html
+++ b/src/backoffice/templates/backoffice/moderation_board-list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#entries').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='entries' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/moderation_user-list.html b/src/backoffice/templates/backoffice/moderation_user-list.html
index cfbc6a49a69819690d3551f9d77cbd2901ca7b91..f44d3494d64a92585be65803ff64e97b667a1884 100644
--- a/src/backoffice/templates/backoffice/moderation_user-list.html
+++ b/src/backoffice/templates/backoffice/moderation_user-list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#users').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='users' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/moderation_wiki-list.html b/src/backoffice/templates/backoffice/moderation_wiki-list.html
index 2ff55a276adc4c7e5d5ddb5e9e105af025b0d631..352334cfab86f5d5359735c440f29733d17e2545 100644
--- a/src/backoffice/templates/backoffice/moderation_wiki-list.html
+++ b/src/backoffice/templates/backoffice/moderation_wiki-list.html
@@ -10,37 +10,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">
-      $(document).ready(function() {
-          $('#pages').DataTable({
-            pageLength: 100,
-            language: {
-              "decimal":        "",
-                "emptyTable":     "{% trans "assembly_info_no_data" %}",
-                "info":           "{% trans "assembly_info_paginated" %}",
-                "infoEmpty":      "{% trans "assembly_info_empty" %}",
-                "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-                "infoPostFix":    "",
-                "thousands":      " ",
-                "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-                "loadingRecords": "LOADING ...",
-                "processing":     "Processing...",
-                "search":         "{% trans "assembly_search" %}",
-                "zeroRecords":    "{% trans "assembly_no_entries" %}",
-                "paginate": {
-                    "first":      "{% trans "assembly_paginate_first" %}",
-                    "last":       "{% trans "assembly_paginate_last" %}",
-                    "next":       "{% trans "assembly_paginate_next" %}",
-                    "previous":   "{% trans "assembly_paginate_previous" %}"
-                },
-                "aria": {
-                    "sortAscending":  ": activate to sort column ascending",
-                    "sortDescending": ": activate to sort column descending"
-                }
-            }
-          });
-      });
-  </script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='pages' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/project/components/list_script.js b/src/backoffice/templates/backoffice/project/components/list_script.js
deleted file mode 100644
index 251081429c71981332649ef57a78a79eacdbf02f..0000000000000000000000000000000000000000
--- a/src/backoffice/templates/backoffice/project/components/list_script.js
+++ /dev/null
@@ -1,30 +0,0 @@
-{% load i18n %}
-$(document).ready(function() {
-  $('#projects').DataTable({
-    pageLength: 100,
-    language: {
-      "decimal":        "",
-        "emptyTable":     "{% trans "assembly_info_no_data" %}",
-        "info":           "{% trans "assembly_info_paginated" %}",
-        "infoEmpty":      "{% trans "assembly_info_empty" %}",
-        "infoFiltered":   "{% trans "assembly_info_filtered" %}",
-        "infoPostFix":    "",
-        "thousands":      " ",
-        "lengthMenu":     "{% trans "assembly_paginate_menu" %}",
-        "loadingRecords": "LOADING ...",
-        "processing":     "Processing...",
-        "search":         "{% trans "assembly_search" %}",
-        "zeroRecords":    "{% trans "assembly_no_entries" %}",
-        "paginate": {
-            "first":      "{% trans "assembly_paginate_first" %}",
-            "last":       "{% trans "assembly_paginate_last" %}",
-            "next":       "{% trans "assembly_paginate_next" %}",
-            "previous":   "{% trans "assembly_paginate_previous" %}"
-        },
-        "aria": {
-            "sortAscending":  ": activate to sort column ascending",
-            "sortDescending": ": activate to sort column descending"
-        }
-    }
-  });
-});
diff --git a/src/backoffice/templates/backoffice/project/list.html b/src/backoffice/templates/backoffice/project/list.html
index 868c9b2f7b0e5fde9ecf2e411f2a6220a3b27649..65a3dd54d5839bc1e752896994c0d795a9980671 100644
--- a/src/backoffice/templates/backoffice/project/list.html
+++ b/src/backoffice/templates/backoffice/project/list.html
@@ -16,7 +16,7 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/project/components/list_script.js" %}</script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='projects' %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/sos.html b/src/backoffice/templates/backoffice/sos.html
index 1dcb9f2c312162083fa8d6f5e7d2f3d5dae4b686..e3499927e1d3e8d48ab9d34f063b36a48884b7be 100644
--- a/src/backoffice/templates/backoffice/sos.html
+++ b/src/backoffice/templates/backoffice/sos.html
@@ -13,8 +13,8 @@
 
 {% block scripts %}
   <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
-  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/event/components/list_script.js" %}</script>
-  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/project/components/list_script.js" %}</script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" %}</script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" %}</script>
 {% endblock scripts %}
 
 {% block content %}
diff --git a/src/backoffice/templates/backoffice/teams/create_edit.html b/src/backoffice/templates/backoffice/teams/create_edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..7d3696ca2bc1f28814a2878e630ebbf339270d60
--- /dev/null
+++ b/src/backoffice/templates/backoffice/teams/create_edit.html
@@ -0,0 +1,60 @@
+{% extends "backoffice/base.html" %}
+{% load django_bootstrap5 %}
+{% load i18n %}
+{% load static %}
+{% load rules %}
+
+{% block title %}
+  {% if form.create %}
+    {% trans "create" %}
+  {% else %}
+    {{ form.instance.name }}
+  {% endif %}
+  | {% trans "Team" %} | {{ conference.name }}
+{% endblock title %}
+{% block content %}
+  {% has_perm 'core.change_permissions_team' request.user team as can_change_permissions %}
+  <form method="post" enctype="multipart/form-data" id="teamForm">
+    {% csrf_token %}
+    <div class="card border-default">
+      <div class="card-header bg-default">
+        {% if form.create %}
+          {% trans "Team__create__title" %}
+        {% else %}
+          {% blocktranslate %}Team__edit__title {{ form.instance.name }}{% endblocktranslate %}
+        {% endif %}
+      </div>
+      <div class="card-body">
+        <p class="fw-bold border-bottom mb-3">{% trans "team__edit__metadata" %}</p>
+        <div class="row mb-3">
+          <div class="col-md-9">{% bootstrap_field form.name %}</div>
+          {% if form.require_staff %}
+            <div class="col-md-3">{% bootstrap_field form.require_staff %}</div>
+          {% else %}
+            <div class="col-md-3">
+              <label for="require_staff">{% trans "Team__require_staff" %}:</label>
+              <span id="require_staff">{{ form.require_staff|yesno }}</span>
+              <div class="form-text">{% trans "Team__require_staff__help" %}</div>
+            </div>
+          {% endif %}
+        </div>
+
+        <div class="row mb-3">
+          <div class="col-md-6">{% bootstrap_field form.description_de %}</div>
+          <div class="col-md-6">{% bootstrap_field form.description_en %}</div>
+        </div>
+        {% if not form.create and can_change_permissions %}
+          <div class="row">
+            <p class="col-md-12">
+              {% trans "Team__change_permission_admin" %} <a href="{% url "admin:core_team_change" team.id %}">test</a>
+            </p>
+          </div>
+        {% endif %}
+      </div>
+      {% trans "Team__edit__submit" as button_text %}
+      <div class="card-footer">
+        {% bootstrap_button button_text button_type="submit" button_class="btn-primary float-end" %}
+      </div>
+    </div>
+  </form>
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/teams/detail.html b/src/backoffice/templates/backoffice/teams/detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..5309f643f502047ac62a2692c4edcd4b802fa845
--- /dev/null
+++ b/src/backoffice/templates/backoffice/teams/detail.html
@@ -0,0 +1,170 @@
+{% extends "backoffice/base.html" %}
+{% load rules %}
+{% load i18n %}
+{% load static %}
+{% load hub_absolute %}
+
+{% block title %}
+  {% trans "Details" %} | {% trans "Team" %} | {{ team.name }}
+{% endblock title %}
+{% block scripts %}
+  <script src="{% static "backoffice/form-add.js" %}"></script>
+  <script src="{% static "backoffice/modal.js" %}"></script>
+  <script nonce="{{ request.csp_nonce }}">
+  $(document).ready(() => {
+    showModal = registerModal()
+    deleteSubmit = document.getElementById('TeamDeleteSubmit')
+    if(deleteSubmit) {
+      deleteSubmit.addEventListener('click', (e) => {
+        e.preventDefault();
+        showModal(
+          () => {
+            form = document.getElementById('TeamDeleteForm')
+            form.action = form.action + '?confirmation=true'
+            form.submit()
+          },
+          'danger',
+          '{% trans "Team__delete__warning__header" %}',
+          '{% blocktrans %}Team__delete__warning__text {{ team }}{% endblocktrans %}',
+          '{% trans "Team__delete__submit" %}')
+      })
+    }
+  });
+  </script>
+{% endblock scripts %}
+{% block content %}
+  {% has_perm 'core.change_team' request.user team as can_change %}
+  {% has_perm 'core.view_details_team' request.user team as can_view_details %}
+  {% has_perm 'core.delete_team' request.user team as can_delete %}
+  <div class="card border-default">
+    <div class="card-header bg-default">{% trans "Details" %} | {% trans "Team" %}</div>
+    <div class="card-body">
+      <div class="row">
+        <label for="team-name" class="form-label col-sm-2">{% trans "Team__name" %}</label>
+        <span class="col-sm-4 " id="team-name">{{ team.name }}</span>
+        <label for="team-staff" class="form-label col-sm-2">{% trans "Team__require_staff" %}</label>
+        <span class="col-sm-4 " id="team-staff">{{ team.require_staff|yesno }}</span>
+      </div>
+      <div class="row">
+        <label for="team-description" class="form-label">{% trans "Team__description" %}</label>
+        <div class="col-md-12">
+          <p id="team-description">
+            {% if team.description %}
+              {{ team.description_html|safe }}
+            {% else %}
+              {% trans "Team__no_description" %}
+            {% endif %}
+          </p>
+        </div>
+      </div>
+    </div>
+    {% if can_change %}
+      <div class="card-footer text-end">
+        <a href="{% url "backoffice:team-edit" uuid=team.uuid %}"
+           class="btn btn-primary btn-sm">{% trans "Team__edit__button" %}</a>
+      </div>
+    {% endif %}
+  </div>
+
+  <div class="card border-default mt-3">
+    <div class="card-header bg-default">{% trans "TeamMembers" %}</div>
+    <div class="card-body">
+      <table class="table table-sm m-0">
+        <thead>
+          <tr>
+            <th>{% trans "name" %}/{% trans "username" %}</th>
+            <th>{% trans "TeamMember__can_manage" %}</th>
+            <th>{% trans "TeamMember__created" %}</th>
+            <th>{% trans "TeamMember__updated" %}</th>
+            {% if can_change %}
+              <th class="text-end">{% trans "TeamMember__actions" %}</th>
+            {% endif %}
+          </tr>
+        </thead>
+        <tbody>
+          {% for member in team.members.all %}
+            {% if not can_view_details and member.can_manage or can_view_details %}
+              <tr>
+                <td>
+                  <a href="{% hub_absolute 'plainui:user' user_slug=member.user.slug %}">{{ member.user.username }}</a>
+                </td>
+                <td>{{ member.can_manage|yesno }}</td>
+                <td>{{ member.created }}</td>
+                <td>{{ member.updated }}</td>
+                {% if can_change %}
+                  <td class="text-end">
+
+                    <form action="{% url "backoffice:team-member-update" team=team.uuid pk=member.id %}"
+                          method="post">
+                      {% csrf_token %}
+                      {% if member.can_manage %}
+                        <input type="hidden" name="can_manage" value="False">
+                        <button type="submit" class="btn btn-sm btn-warning">{% trans "TeamMember__demote_button" %}</button>
+                      {% else %}
+                        <input type="hidden" name="can_manage" value="True">
+                        <button type="submit" class="btn btn-sm btn-primary">{% trans "TeamMember__promote_button" %}</button>
+                      {% endif %}
+                      <a href="{% url 'backoffice:team-member-delete' team=team.uuid pk=member.id %}"
+                         class="btn btn-sm btn-danger">{% trans "TeamMember__remove__button" %}</a>
+                    </form>
+                  </td>
+                {% endif %}
+              </tr>
+            {% endif %}
+          {% endfor %}
+        </tbody>
+
+      </table>
+    </div>
+    <div class="card-footer text-end">
+      {% if member_id %}
+        <a href="{% url "backoffice:team-member-delete" team=team.uuid pk=member_id %}"
+           class="btn btn-danger btn-sm">{% trans "TeamMember__leave" %}</a>
+      {% else %}
+        <a href="{% url "backoffice:invitation-send" type="member_to_team" requester_id=user.uuid requested_id=team.uuid %}"
+           class="btn btn-primary btn-sm">{% trans "TeamMember__join" %}</a>
+      {% endif %}
+      {% if can_change %}
+        <a href="{% url "backoffice:invitation-send" type="team_to_member" requester_id=team.uuid %}"
+           class="btn btn-primary btn-sm">{% trans "TeamMember__invite" %}</a>
+      {% endif %}
+    </div>
+  </div>
+  {% if received_invitations and can_change %}
+    {% trans "Invitations__list__title" as list_title %}
+    {% include "backoffice/components/invitations_list.html" with invitations=received_invitations border_class="border-primary" list_title=list_title requested_view=True %}
+  {% endif %}
+
+  {% if sent_invitations and can_change %}
+    {% trans "Invitations__list__title" as list_title %}
+    {% include "backoffice/components/invitations_list.html" with invitations=sent_invitations border_class="border-secondary" list_title=list_title requester_view=True %}
+  {% endif %}
+
+  {% if not form.create and can_delete %}
+    <div class="card card-danger border-danger mt-3 mb-3">
+      <div class="card-header bg-default text-danger">{% trans "Team__delete__title" %}</div>
+      <div class="card-body">
+        <p>{% trans "Team__delete__introduction" %}</p>
+      </div>
+      <div class="card-footer text-end">
+
+        <form class="form"
+              action="{% url "backoffice:team-delete" uuid=team.uuid %}"
+              method="post"
+              id="TeamDeleteForm">
+          {% csrf_token %}
+          <button type="submit" class="btn btn-sm btn-danger" id="TeamDeleteSubmit">
+            {% trans "Team__delete__button" %}: {{ object.name }}
+          </button>
+        </form>
+      </div>
+    </div>
+  {% endif %}
+  {% if can_change %}
+    <div class="row mt-3">
+      <div class="col-md-12">
+        {% include "backoffice/components/activity_log_card.html" with filtered_logentry_changes_keys="" %}
+      </div>
+    </div>
+  {% endif %}
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/teams/list.html b/src/backoffice/templates/backoffice/teams/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..a42244d76e338b2ef969aac6cff2707497ce4672
--- /dev/null
+++ b/src/backoffice/templates/backoffice/teams/list.html
@@ -0,0 +1,69 @@
+{% extends "backoffice/base.html" %}
+{% load rules %}
+{% load i18n %}
+{% load static %}
+
+{% block htmlhead %}
+  <link rel="stylesheet"
+        href="{% static 'vendor/datatables/datatables.min.css' %}">
+{% endblock htmlhead %}
+
+{% block scripts %}
+  <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='teams' %}</script>
+{% endblock scripts %}
+
+{% block content %}
+  {% has_perm 'books.add_book' request.user as can_add_team %}
+  <div class="card">
+    <div class="card-header">
+      <span class="text-muted">{% trans "Teams" %}:</span> {{ mode_display }}
+    </div>
+    <div class="card-body">
+
+      {% if filter_tag is not None %}
+        <div class="alert alert-info">
+          {% trans "assemblies_filtered_tag" %}: <strong>{{ filter_tag }}</strong>
+        </div>
+      {% endif %}
+
+      <table class="table table-sm" id="teams">
+        <thead>
+          <tr>
+            <th>{% trans "Team__name" %}</th>
+            <th>{% trans "Team__require_staff" %}</th>
+            {% if user.is_staff %}
+              <th>{% trans "Team__member_count" %}</th>
+            {% endif %}
+            <th>{% trans "TeamMember__can_manage" %}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for team in object_list %}
+            <tr>
+              <td>
+                <a href="{% url 'backoffice:team' uuid=team.uuid %}">{{ team.name }}</a>
+              </td>
+              <td>{{ team.require_staff|yesno }}</td>
+              {% if user.is_staff %}<td>{{ team.members_count }}</td>{% endif %}
+              <td>
+                {% if user.id in team.member_list %}
+                  {{ team.can_manage|yesno }}
+                {% else %}
+                  {% trans "Team__list__not_a_member" %}
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+    </div>
+    {% if can_add_team %}
+      <div class="card-footer text-end">
+        <a href="{% url 'backoffice:team-create' %}"
+           class="btn btn-primary btn-sm">{% trans "Team__create__button" %}</a>
+      </div>
+    {% endif %}
+  </div>
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/translation_stubs.html b/src/backoffice/templates/backoffice/translation_stubs.html
new file mode 100644
index 0000000000000000000000000000000000000000..d05d5df3ab1fae31d266ce02855691d3bfd3df96
--- /dev/null
+++ b/src/backoffice/templates/backoffice/translation_stubs.html
@@ -0,0 +1,17 @@
+{% load i18n %}
+
+{% trans "data_table__info__no_data" %}",
+{% trans "data_table__info__paginated" %}",
+{% trans "data_table__info__empty" %}",
+{% trans "data_table__info__filtered" %}",
+{% trans "data_table__paginate__menu" %}",
+{% trans "data_table__loading" %}",
+{% trans "data_table__processing" %}",
+{% trans "data_table_search" %}",
+{% trans "data_table__no_entries" %}",
+{% trans "data_table__paginate__first" %}",
+{% trans "data_table__paginate__last" %}",
+{% trans "data_table__paginate__next" %}",
+{% trans "data_table__paginate__previous" %}"
+{% trans "data_table__aria__sort_ascending" %}", {#activate to sort column ascending#}
+{% trans "data_table__aria__sort_descending" %}", {#activate to sort column descending#}
diff --git a/src/backoffice/tests/__init__.py b/src/backoffice/tests/__init__.py
index 5cb8812d2ed9226a476d237cdea6d1faf80d9138..92713085fa0a48a1cf0c905b6330a5695aaf2de8 100644
--- a/src/backoffice/tests/__init__.py
+++ b/src/backoffice/tests/__init__.py
@@ -2,5 +2,6 @@ from .base import *  # noqa: F401, F403, I001
 from .assemblies import *  # noqa: F401, F403
 from .auth import *  # noqa: F401, F403
 from .invitations import *  # noqa: F401, F403
+from .teams import *  # noqa: F401, F403
 
 __all__ = ('*',)  # noqa: F405
diff --git a/src/backoffice/tests/base.py b/src/backoffice/tests/base.py
index e793eec0d50da48b2089871adcd8dd9c043025f4..31189a9e30de9808d2ed6b4d8cc0b605fec99969 100644
--- a/src/backoffice/tests/base.py
+++ b/src/backoffice/tests/base.py
@@ -25,10 +25,15 @@ class BackOfficeTestCase(TestCase):
             is_public=True,
         )
         self.conf.save()
-        self.user = PlatformUser(username='testuser', email='no@where.test', is_staff=True, is_superuser=True)
-        self.user.save()
-        self.conference_member = ConferenceMember(conference=self.conf, user=self.user)
-        self.conference_member.save()
+        self.admin = PlatformUser.objects.create(
+            username='test_admin',
+            email='no@where.test',
+            is_staff=True,
+            is_superuser=True,
+        )
+        self.admin_member = ConferenceMember.objects.create(conference=self.conf, user=self.admin)
+        self.user = PlatformUser.objects.create(username='test_user', email='no@where.test')
+        self.user_cm = ConferenceMember.objects.create(conference=self.conf, user=self.user)
         self.staff = PlatformUser.objects.create(username='test_staff', email='staff@where.test')
         self.staff_cm = ConferenceMember.objects.create(conference=self.conf, user=self.staff, is_staff=True)
         self.staff_cm.permission_groups.add(Group.objects.get(name='Assembly-Team'))
diff --git a/src/backoffice/tests/invitations/__init__.py b/src/backoffice/tests/invitations/__init__.py
index da1a5a862123ed42f03f9264930204291de78b19..7301a10f8487203d7928c6f87b8b4fbc2591e578 100644
--- a/src/backoffice/tests/invitations/__init__.py
+++ b/src/backoffice/tests/invitations/__init__.py
@@ -1,18 +1,114 @@
+from uuid import uuid4
+
 from django.urls import reverse
 
+from core.models import (
+    Assembly,
+    AssemblyMember,
+    ConferenceMember,
+    Invitation,
+    PlatformUser,
+    Team,
+    TeamMember,
+)
+
 from backoffice.tests.base import BackOfficeTestCase
-from backoffice.tests.invitations.habitat import InvitationHabitatSendViewTestCase
+from backoffice.tests.invitations.habitat import HabitatInvitationViewsTestCase
+from backoffice.tests.invitations.teams import MemberToTeamInvitationViewsTestCase, TeamToUserInvitationViewsTestCase
 
 
 class InvitationTestCase(BackOfficeTestCase):
     def test_invalid_invitation_type(self):
         self.client.force_login(self.staff)
         response = self.client.post(
-            reverse('backoffice:invitation-send', kwargs={'type': 'invalid'}),
+            reverse(
+                'backoffice:invitation-send',
+                kwargs={'type': 'invalid', 'requester_id': uuid4()},
+            ),
+        )
+        self.assertContains(response, 'Bad Request', status_code=400)
+
+
+class InvitationListViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.assembly = Assembly.objects.create(name='Assembly 1', slug='assembly_1', conference=self.conf)
+        self.assembly_manager_am = AssemblyMember.objects.create(
+            assembly=self.assembly,
+            member=self.user,
+            is_representative=True,
+            can_manage_assembly=True,
+        )
+        self.habitat = Assembly.objects.create(name='Habitat 1', slug='habitat_1', conference=self.conf, hierarchy=Assembly.Hierarchy.CLUSTER)
+        self.habitat_manager = PlatformUser.objects.create(username='habitat_manager')
+        self.habitat_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.habitat_manager)
+        self.habitat_manager_am = AssemblyMember.objects.create(
+            assembly=self.habitat,
+            member=self.habitat_manager,
+            is_representative=True,
+            can_manage_assembly=True,
+        )
+
+        self.team = Team.objects.create(name='team 1', conference=self.conf)
+        self.team_manager = PlatformUser.objects.create(username='assembly_manager')
+        self.team_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.team_manager)
+        self.team_manager_am = TeamMember.objects.create(
+            team=self.team,
+            user=self.team_manager,
+            can_manage=True,
         )
-        self.assertContains(response, 'Invalid invitation type', status_code=400)
+
+        Invitation.objects.create(
+            requester=self.user,
+            requested=self.team,
+            conference=self.conf,
+            state=Invitation.RequestsState.REQUESTED,
+            type=Invitation.InvitationType.MEMBER_TO_TEAM,
+        )
+
+        Invitation.objects.create(
+            requester=self.user,
+            requested=self.team,
+            conference=self.conf,
+            state=Invitation.RequestsState.WITHDRAWN,
+            type=Invitation.InvitationType.MEMBER_TO_TEAM,
+        )
+        Invitation.objects.create(
+            requester=self.user,
+            requested=self.team,
+            conference=self.conf,
+            state=Invitation.RequestsState.REJECTED,
+            type=Invitation.InvitationType.MEMBER_TO_TEAM,
+        )
+        Invitation.objects.create(
+            requester=self.habitat,
+            requested=self.assembly,
+            conference=self.conf,
+            state=Invitation.RequestsState.REQUESTED,
+            type=Invitation.InvitationType.HABITAT,
+        )
+        Invitation.objects.create(
+            requester=self.assembly,
+            requested=self.habitat,
+            conference=self.conf,
+            state=Invitation.RequestsState.REQUESTED,
+            type=Invitation.InvitationType.HABITAT,
+        )
+
+    def test_invitation_list(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:invitations'))
+        self.assertContains(response, 'team 1')
+        self.assertContains(response, 'Habitat 1', count=2)
+        self.assertContains(response, 'Assembly 1', count=2)
+        self.assertEqual(len(response.context['invitations']), 3)
 
 
 __all__ = [
-    'InvitationHabitatSendViewTestCase',
+    'HabitatInvitationViewsTestCase',
+    'InvitationListViewTestCase',
+    'InvitationTestCase',
+    'MemberToTeamInvitationViewsTestCase',
+    'TeamToUserInvitationViewsTestCase',
 ]
diff --git a/src/backoffice/tests/invitations/habitat.py b/src/backoffice/tests/invitations/habitat.py
index f911540364a14ca2cfae3545e8b43afca671fcc6..fa07b3cd04a74e69f2ecf561246c9e849a7e7594 100644
--- a/src/backoffice/tests/invitations/habitat.py
+++ b/src/backoffice/tests/invitations/habitat.py
@@ -14,10 +14,10 @@ from core.models import (
 )
 from core.tests.mock import mocktrans
 
-from backoffice.tests.base import BackOfficeTestCase
+from backoffice.tests.invitations.mixin import InvitationTestCase
 
 
-class InvitationHabitatTestCase(BackOfficeTestCase):
+class InvitationHabitatTestCase(InvitationTestCase):
     def setUp(self):
         super().setUp()
 
@@ -41,207 +41,103 @@ class InvitationHabitatTestCase(BackOfficeTestCase):
         )
 
 
-class InvitationHabitatSendViewTestCase(InvitationHabitatTestCase):
-    def test_missing_requester_id(self):
-        self.client.force_login(self.habitat_manager)
-        response = self.client.post(
-            reverse('backoffice:invitation-send', kwargs={'type': 'habitat'}),
-        )
-        self.assertContains(response, 'Requester ID is required', status_code=400)
-
+class HabitatInvitationViewsTestCase(InvitationHabitatTestCase):
     def test_send_habitat_assembly_invitation_accept(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
         # Accept invitation
-        self.client.force_login(self.assembly_manager)
         self.assertIsNone(self.assembly.parent)
         activity_log_count = ActivityLogEntry.objects.count()
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'accept',
-            },
+        self.send_action(
+            user=self.assembly_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
         )
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-        self.assembly.refresh_from_db()
-        invitation.refresh_from_db()
-
-        self.assertEqual(invitation.decision_by, self.assembly_manager)
-        self.assertEqual(invitation.state, Invitation.RequestsState.ACCEPTED)
 
+        self.assembly.refresh_from_db()
         self.assertEqual(self.assembly.parent, self.habitat)
         self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2)
 
     def test_send_assembly_habitat_invitation_accept(self):
-        self.client.force_login(self.assembly_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.assembly.id,
-                },
-            ),
-            data={
-                'requester_id': self.assembly.id,
-                'requested_id': self.habitat.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.assembly_manager,
+            requester_id=self.assembly.id,
+            requested_id=self.habitat.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.assembly_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
         # Accept invitation
-        self.client.force_login(self.habitat_manager)
         self.assertIsNone(self.assembly.parent)
         activity_log_count = ActivityLogEntry.objects.count()
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'accept',
-            },
+        self.send_action(
+            user=self.habitat_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
         )
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-        self.assembly.refresh_from_db()
-        invitation.refresh_from_db()
-
-        self.assertEqual(invitation.decision_by, self.habitat_manager)
-        self.assertEqual(invitation.state, Invitation.RequestsState.ACCEPTED)
 
+        self.assembly.refresh_from_db()
         self.assertEqual(self.assembly.parent, self.habitat)
         self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2)
 
     def test_send_habitat_invitation_withdraw(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'withdraw',
-            },
+        self.send_action(
+            user=self.habitat_manager,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.WITHDRAWN,
         )
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation.refresh_from_db()
-        self.assertEqual(invitation.decision_by, self.habitat_manager)
-        self.assertEqual(invitation.state, Invitation.RequestsState.WITHDRAWN)
 
         # Test if we can send the invitation again
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
+            exprected_count=2,
         )
-        self.assertEqual(Invitation.objects.count(), 2)
-        invitation = Invitation.objects.filter(state=Invitation.RequestsState.REQUESTED).first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
     def test_send_habitat_invitation_reject(self):
         with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)):
-            self.client.force_login(self.habitat_manager)
-            # Send invitation from habitat to assembly
-            response = self.client.post(
-                reverse(
-                    'backoffice:invitation-send-with-pk',
-                    kwargs={
-                        'type': 'habitat',
-                        'requester_id': self.habitat.id,
-                    },
-                ),
-                data={
-                    'requester_id': self.habitat.id,
-                    'requested_id': self.assembly.id,
-                },
+            _response, invitation = self.send_successful_invitation(
+                user=self.habitat_manager,
+                requester_id=self.habitat.id,
+                requested_id=self.assembly.id,
+                invitation_type=Invitation.InvitationType.HABITAT,
             )
-            self.assertEqual(Invitation.objects.count(), 1)
-            invitation = Invitation.objects.first()
-            self.assertEqual(invitation.sender, self.habitat_manager)
-            self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-            self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-
-            self.client.force_login(self.assembly_manager)
-            response = self.client.post(
-                reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-                data={
-                    'action': 'reject',
-                },
+
+            self.send_action(
+                user=self.assembly_manager,
+                invitation=invitation,
+                action='reject',
+                expected_state=Invitation.RequestsState.REJECTED,
             )
-            self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-            self.assertEqual(Invitation.objects.count(), 1)
-            invitation.refresh_from_db()
-            self.assertEqual(invitation.decision_by, self.assembly_manager)
-            self.assertEqual(invitation.state, Invitation.RequestsState.REJECTED)
 
             self.client.force_login(self.habitat_manager)
             # Try to re-Send invitation from habitat to assembly
             with patch('core.models.invitation._', mocktrans):
-                response = self.client.post(
-                    reverse(
-                        'backoffice:invitation-send-with-pk',
-                        kwargs={
-                            'type': 'habitat',
-                            'requester_id': self.habitat.id,
-                        },
-                    ),
-                    data={
-                        'requester_id': self.habitat.id,
-                        'requested_id': self.assembly.id,
-                    },
+                response = self.send_invitation(
+                    user=self.habitat_manager,
+                    requester_id=self.habitat.id,
+                    requested_id=self.assembly.id,
+                    invitation_type=Invitation.InvitationType.HABITAT,
+                    expected_code=200,
                 )
-            self.assertTemplateUsed(response, 'backoffice/invitations/create_edit_habitat.html')
-            self.assertContains(response, 'Invitation__error__rejected_timeout_translated', status_code=200)
-            self.assertEqual(Invitation.objects.count(), 1)
+
+            self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
 
         # After the rejection has timed out, can send invitation again
         with (
@@ -249,241 +145,163 @@ class InvitationHabitatSendViewTestCase(InvitationHabitatTestCase):
             patch('core.models.invitation.datetime') as mock_datetime,
         ):
             mock_datetime.now.return_value = datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC) + timedelta(hours=4)
-            response = self.client.post(
-                reverse(
-                    'backoffice:invitation-send-with-pk',
-                    kwargs={
-                        'type': 'habitat',
-                        'requester_id': self.habitat.id,
-                    },
-                ),
-                data={
-                    'requester_id': self.habitat.id,
-                    'requested_id': self.assembly.id,
-                },
+            _response, invitation_2 = self.send_successful_invitation(
+                user=self.habitat_manager,
+                requester_id=self.habitat.id,
+                requested_id=self.assembly.id,
+                invitation_type=Invitation.InvitationType.HABITAT,
+                exprected_count=2,
             )
-            self.assertEqual(Invitation.objects.count(), 2)
-            invitation_2 = Invitation.objects.exclude(id=invitation.id).first()
-            self.assertEqual(invitation_2.sender, self.habitat_manager)
-            self.assertEqual(invitation_2.type, Invitation.InvitationType.HABITAT)
-            self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation_2.id}))
 
             # Accept invitation
-            self.client.force_login(self.assembly_manager)
             self.assertIsNone(self.assembly.parent)
             activity_log_count = ActivityLogEntry.objects.count()
-            response = self.client.post(
-                reverse('backoffice:invitation', kwargs={'pk': invitation_2.id}),
-                data={
-                    'action': 'accept',
-                },
-            )
-            self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation_2.id}))
-            self.assembly.refresh_from_db()
-            invitation_2.refresh_from_db()
 
-            self.assertEqual(invitation_2.decision_by, self.assembly_manager)
-            self.assertEqual(invitation_2.state, Invitation.RequestsState.ACCEPTED)
+            self.send_action(
+                user=self.assembly_manager,
+                invitation=invitation_2,
+                action='accept',
+                expected_state=Invitation.RequestsState.ACCEPTED,
+            )
 
+            self.assembly.refresh_from_db()
             self.assertEqual(self.assembly.parent, self.habitat)
             self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2)
 
     def test_send_habitat_invitation_update(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        assert invitation
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
+
+        with patch('backoffice.views.invitations.update._', mocktrans):
+            response = self.client.get(
+                reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}),
+            )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+        self.assertContains(response, 'Invitation__introduction__habitat_translated')
+        self.assertContains(response, 'Invitation__type__habitat_translated')
+
+        response = self.send_action(
+            user=self.habitat_manager,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
             data={
-                'action': 'update',
                 'requester_id': invitation.requester.id,
                 'requested_id': invitation.requested.id,
                 'comment': 'Updated comment',
             },
         )
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
-        self.assertEqual(Invitation.objects.count(), 1)
         invitation.refresh_from_db()
-        self.assertEqual(invitation.decision_by, None)
-        self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED)
         self.assertEqual(invitation.comment, 'Updated comment')
 
     def test_send_habitat_invitation_invalid_action(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
-        self.client.force_login(self.assembly_manager)
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'invalid',
-            },
+        self.send_action(
+            user=self.assembly_manager,
+            invitation=invitation,
+            action='invalid',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=400,
         )
-        self.assertContains(response, 'Invalid action', status_code=400)
 
     def test_no_permission(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        _response, invitation = self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
         # Cannot accept invitation
         self.assertIsNone(self.assembly.parent)
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'accept',
-            },
+
+        self.send_action(
+            user=self.habitat_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
         )
-        self.assertContains(response, 'Forbidden', status_code=403)
         self.assembly.refresh_from_db()
         self.assertIsNone(self.assembly.parent)
-        invitation.refresh_from_db()
-        self.assertIsNone(invitation.decision_by)
-        self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED)
 
         # Cannot reject invitation
         self.assertIsNone(self.assembly.parent)
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'reject',
-            },
+
+        self.send_action(
+            user=self.habitat_manager,
+            invitation=invitation,
+            action='reject',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
         )
-        self.assertContains(response, 'Forbidden', status_code=403)
         self.assembly.refresh_from_db()
         self.assertIsNone(self.assembly.parent)
-        invitation.refresh_from_db()
-        self.assertIsNone(invitation.decision_by)
-        self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED)
 
         self.client.force_login(self.assembly_manager)
         # Cannot withdraw invitation
         self.assertIsNone(self.assembly.parent)
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
-            data={
-                'action': 'withdraw',
-            },
+        self.send_action(
+            user=self.assembly_manager,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
         )
-        self.assertContains(response, 'Forbidden', status_code=403)
         self.assembly.refresh_from_db()
         self.assertIsNone(self.assembly.parent)
-        invitation.refresh_from_db()
-        self.assertIsNone(invitation.decision_by)
-        self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED)
 
         # Cannot update invitation
         self.assertIsNone(self.assembly.parent)
-        response = self.client.post(
-            reverse('backoffice:invitation', kwargs={'pk': invitation.id}),
+        self.send_action(
+            user=self.assembly_manager,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
             data={
-                'action': 'update',
                 'requester_id': invitation.requester.id,
                 'requested_id': invitation.requested.id,
                 'comment': 'Updated comment',
             },
         )
-        self.assertContains(response, 'Forbidden', status_code=403)
+
         self.assembly.refresh_from_db()
         self.assertIsNone(self.assembly.parent)
         invitation.refresh_from_db()
-        self.assertIsNone(invitation.decision_by)
-        self.assertEqual(invitation.state, Invitation.RequestsState.REQUESTED)
         self.assertEqual(invitation.comment, '')
 
     def test_duplicate_invitation(self):
-        self.client.force_login(self.habitat_manager)
-        # Send invitation from habitat to assembly
-        response = self.client.post(
-            reverse(
-                'backoffice:invitation-send-with-pk',
-                kwargs={
-                    'type': 'habitat',
-                    'requester_id': self.habitat.id,
-                },
-            ),
-            data={
-                'requester_id': self.habitat.id,
-                'requested_id': self.assembly.id,
-            },
+        self.send_successful_invitation(
+            user=self.habitat_manager,
+            requester_id=self.habitat.id,
+            requested_id=self.assembly.id,
+            invitation_type=Invitation.InvitationType.HABITAT,
         )
-        self.assertEqual(Invitation.objects.count(), 1)
-        invitation = Invitation.objects.first()
-        self.assertEqual(invitation.sender, self.habitat_manager)
-        self.assertEqual(invitation.type, Invitation.InvitationType.HABITAT)
-        self.assertRedirects(response, reverse('backoffice:invitation', kwargs={'pk': invitation.id}))
 
         # Try to send duplicate invitation
 
         # Send invitation from habitat to assembly
         with patch('core.models.invitation._', mocktrans):
-            response = self.client.post(
-                reverse(
-                    'backoffice:invitation-send-with-pk',
-                    kwargs={
-                        'type': 'habitat',
-                        'requester_id': self.habitat.id,
-                    },
-                ),
-                data={
-                    'requester_id': self.habitat.id,
-                    'requested_id': self.assembly.id,
-                },
+            response = self.send_invitation(
+                user=self.habitat_manager,
+                requester_id=self.habitat.id,
+                requested_id=self.assembly.id,
+                invitation_type=Invitation.InvitationType.HABITAT,
+                expected_code=200,
             )
-        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit_habitat.html')
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
         self.assertContains(response, f'Invitation__error__already_exists {self.habitat.name} {self.assembly.name} {self.habitat_manager.username}_translated')
         self.assertEqual(Invitation.objects.count(), 1)
diff --git a/src/backoffice/tests/invitations/mixin.py b/src/backoffice/tests/invitations/mixin.py
new file mode 100644
index 0000000000000000000000000000000000000000..59443a6798f9906bca614a3983f4a04b021c8438
--- /dev/null
+++ b/src/backoffice/tests/invitations/mixin.py
@@ -0,0 +1,103 @@
+from typing import Any
+from uuid import UUID
+
+from django.http import HttpResponse
+from django.urls import reverse
+
+from core.models import (
+    Invitation,
+    PlatformUser,
+)
+
+from backoffice.tests.base import BackOfficeTestCase
+
+
+class InvitationTestCase(BackOfficeTestCase):
+    def send_invitation(
+        self,
+        *,
+        user: PlatformUser,
+        requester_id: str | UUID,
+        requested_id: str | UUID,
+        invitation_type: Invitation.InvitationType,
+        url: str | None = None,
+        exprected_count: int = 1,
+        expected_code: int = 302,
+    ) -> HttpResponse:
+        self.client.force_login(user)
+        response = self.client.post(
+            url
+            if url
+            else reverse(
+                'backoffice:invitation-send',
+                kwargs={
+                    'type': invitation_type.lower(),
+                    'requester_id': requester_id,
+                },
+            ),
+            data={
+                'requester_id': requester_id,
+                'requested_id': requested_id,
+            },
+        )
+        self.assertEqual(Invitation.objects.count(), exprected_count)
+        self.assertEqual(response.status_code, expected_code)
+        return response
+
+    def send_successful_invitation(
+        self,
+        *,
+        user: PlatformUser,
+        invitation_type: Invitation.InvitationType,
+        expected_code: int = 302,
+        **kwargs: Any,
+    ) -> tuple[HttpResponse, Invitation]:
+        response = self.send_invitation(
+            user=user,
+            invitation_type=invitation_type,
+            expected_code=expected_code,
+            **kwargs,
+        )
+        invitation = Invitation.objects.filter(state=Invitation.RequestsState.REQUESTED).first()
+        assert invitation
+        self.assertEqual(invitation.sender, user)
+        self.assertEqual(invitation.type, invitation_type)
+        if expected_code == 302:
+            self.assertRedirects(response, reverse('backoffice:invitation-detail', kwargs={'pk': invitation.id}))
+        return (response, invitation)
+
+    def send_action(
+        self,
+        *,
+        user: PlatformUser,
+        invitation: Invitation,
+        action: str,
+        expected_code: int = 302,
+        expected_state: Invitation.RequestsState | None = None,
+        expected_count: int = 1,
+        data: dict[str, Any] | None = None,
+    ) -> HttpResponse:
+        data = data or {}
+        if action == 'update':
+            url = reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id})
+        else:
+            data['action'] = action
+            url = reverse('backoffice:invitation-decision', kwargs={'pk': invitation.id})
+
+        self.client.force_login(user)
+        response = self.client.post(
+            url,
+            data=data,
+        )
+        self.assertEqual(response.status_code, expected_code)
+        self.assertEqual(Invitation.objects.filter(state=expected_state).count(), expected_count)
+        if expected_code == 302:
+            self.assertRedirects(response, reverse('backoffice:invitation-detail', kwargs={'pk': invitation.id}))
+        if expected_state:
+            invitation.refresh_from_db()
+            self.assertEqual(invitation.state, expected_state)
+            if expected_state != Invitation.RequestsState.REQUESTED:
+                self.assertEqual(invitation.decision_by, user)
+            else:
+                self.assertIsNone(invitation.decision_by)
+        return response
diff --git a/src/backoffice/tests/invitations/teams.py b/src/backoffice/tests/invitations/teams.py
new file mode 100644
index 0000000000000000000000000000000000000000..e286717c3776be50930d7ae21c6e980aff31bfe1
--- /dev/null
+++ b/src/backoffice/tests/invitations/teams.py
@@ -0,0 +1,544 @@
+from datetime import UTC, datetime, timedelta
+from unittest.mock import patch
+from uuid import UUID
+
+from django.http import HttpResponse
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.html import escape
+
+from core.models import (
+    ActivityLogEntry,
+    ConferenceMember,
+    Invitation,
+    PlatformUser,
+    Team,
+    TeamMember,
+)
+from core.tests.mock import mocktrans
+
+from backoffice.tests.invitations.mixin import InvitationTestCase
+
+
+class TeamToUserInvitationViewsTestCase(InvitationTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.team = Team.objects.create(name='team 1', conference=self.conf)
+        self.team_manager = PlatformUser.objects.create(username='assembly_manager')
+        self.team_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.team_manager)
+        self.team_manager_am = TeamMember.objects.create(
+            team=self.team,
+            user=self.team_manager,
+            can_manage=True,
+        )
+
+        self.invitee = PlatformUser.objects.create(username='invitee', first_name='First', last_name='Last')
+        self.invitee_cm = ConferenceMember.objects.create(conference=self.conf, user=self.invitee)
+
+    def send_successful_invitation(
+        self,
+        user: PlatformUser | None = None,
+        requester_id: str | UUID | None = None,
+        requested_id: str | UUID | None = None,
+        invitation_type: Invitation.InvitationType = Invitation.InvitationType.TEAM_TO_MEMBER,
+        **kwargs,
+    ) -> tuple[HttpResponse, Invitation]:
+        return super().send_successful_invitation(
+            user=self.team_manager if user is None else user,
+            requester_id=self.team.uuid if requester_id is None else requester_id,
+            requested_id=self.invitee.username if requested_id is None else requested_id,
+            invitation_type=invitation_type,
+            **kwargs,
+        )
+
+    def send_invitation(
+        self,
+        *,
+        user: PlatformUser | None = None,
+        requester_id: str | UUID | None = None,
+        requested_id: str | UUID | None = None,
+        invitation_type: Invitation.InvitationType = Invitation.InvitationType.TEAM_TO_MEMBER,
+        **kwargs,
+    ) -> HttpResponse:
+        return super().send_invitation(
+            user=self.team_manager if user is None else user,
+            requester_id=self.team.uuid if requester_id is None else requester_id,
+            requested_id=self.invitee.username if requested_id is None else requested_id,
+            invitation_type=invitation_type,
+            **kwargs,
+        )
+
+    def test_team_to_user_invitation_accept(self):
+        _response, invitation = self.send_successful_invitation()
+
+        # Accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        activity_log_count = ActivityLogEntry.objects.count()
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
+        )
+
+        self.team.refresh_from_db()
+        self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_team_to_user_invitation_requested_in_url_accept(self):
+        _response, invitation = self.send_successful_invitation(
+            url=reverse(
+                'backoffice:invitation-send',
+                kwargs={
+                    'requester_id': self.team.uuid,
+                    'requested_id': self.invitee.uuid,
+                    'type': Invitation.InvitationType.TEAM_TO_MEMBER.lower(),
+                },
+            ),
+        )
+
+        # Accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        activity_log_count = ActivityLogEntry.objects.count()
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
+        )
+
+        self.team.refresh_from_db()
+        self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_team_to_user_invitation_withdraw(self):
+        _response, invitation = self.send_successful_invitation()
+
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.WITHDRAWN,
+        )
+
+        # Test if we can send the invitation again
+        _response, invitation = self.send_successful_invitation(
+            exprected_count=2,
+        )
+
+    def test_team_to_user_invitation_reject(self):
+        with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)):
+            _response, invitation = self.send_successful_invitation()
+
+            self.send_action(
+                user=self.invitee,
+                invitation=invitation,
+                action='reject',
+                expected_state=Invitation.RequestsState.REJECTED,
+            )
+
+            # Try to re-Send invitation from habitat to assembly
+            with patch('core.models.invitation._', mocktrans):
+                response = self.send_invitation(
+                    expected_code=200,
+                )
+
+            self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+
+        # After the rejection has timed out, can send invitation again
+        with (
+            patch.object(timezone, 'now', return_value=(datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)) + timedelta(hours=4)),
+            patch('core.models.invitation.datetime') as mock_datetime,
+        ):
+            mock_datetime.now.return_value = datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC) + timedelta(hours=4)
+            _response, invitation_2 = self.send_successful_invitation(exprected_count=2)
+
+            # Accept invitation
+            self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+            activity_log_count = ActivityLogEntry.objects.count()
+
+            self.send_action(
+                user=self.invitee,
+                invitation=invitation_2,
+                action='accept',
+                expected_state=Invitation.RequestsState.ACCEPTED,
+            )
+
+            self.team.refresh_from_db()
+            self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+            self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_team_to_user_invitation_update(self):
+        _response, invitation = self.send_successful_invitation()
+
+        with patch('backoffice.views.invitations.update._', mocktrans):
+            response = self.client.get(
+                reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}),
+            )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+        self.assertContains(response, 'Invitation__introduction__team_translated')
+        self.assertContains(response, 'Invitation__type__team_to_member_translated')
+
+        response = self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            data={
+                'requester_id': invitation.requester.id,
+                'requested_id': invitation.requested.id,
+                'comment': 'Updated comment',
+            },
+        )
+        invitation.refresh_from_db()
+        self.assertEqual(invitation.comment, 'Updated comment')
+
+    def test_team_to_user_invitation_invalid_action(self):
+        _response, invitation = self.send_successful_invitation()
+
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='invalid',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=400,
+        )
+
+    def test_team_to_user_invitation_no_permission(self):
+        _response, invitation = self.send_successful_invitation()
+
+        # Cannot accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        # Cannot reject invitation
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='reject',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+
+        # Cannot withdraw invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+
+        # Cannot update invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+            data={
+                'requester_id': invitation.requester.id,
+                'requested_id': invitation.requested.id,
+                'comment': 'Updated comment',
+            },
+        )
+
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        invitation.refresh_from_db()
+        self.assertEqual(invitation.comment, '')
+
+    def test_team_to_user_invitation_duplicate_invitation(self):
+        self.send_successful_invitation()
+
+        # Try to send duplicate invitation
+
+        # Send invitation from habitat to assembly
+        with patch('core.models.invitation._', mocktrans):
+            response = self.send_invitation(
+                expected_code=200,
+            )
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+        self.assertContains(response, escape(f'Invitation__error__already_exists {self.team.name} {self.invitee.name} {self.team_manager.name}_translated'))
+        self.assertEqual(Invitation.objects.count(), 1)
+
+
+class MemberToTeamInvitationViewsTestCase(InvitationTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.team = Team.objects.create(name='team 1', conference=self.conf)
+        self.team_manager = PlatformUser.objects.create(username='assembly_manager')
+        self.team_manager_cm = ConferenceMember.objects.create(conference=self.conf, user=self.team_manager)
+        self.team_manager_am = TeamMember.objects.create(
+            team=self.team,
+            user=self.team_manager,
+            can_manage=True,
+        )
+
+        self.invitee = PlatformUser.objects.create(username='invitee')
+        self.invitee_cm = ConferenceMember.objects.create(conference=self.conf, user=self.invitee)
+
+    def send_successful_invitation(
+        self,
+        user: PlatformUser | None = None,
+        requester_id: str | UUID | None = None,
+        requested_id: str | UUID | None = None,
+        invitation_type: Invitation.InvitationType = Invitation.InvitationType.MEMBER_TO_TEAM,
+        **kwargs,
+    ) -> tuple[HttpResponse, Invitation]:
+        return super().send_successful_invitation(
+            user=self.invitee if user is None else user,
+            requester_id=self.invitee.uuid if requester_id is None else requester_id,
+            requested_id=self.team.name if requested_id is None else requested_id,
+            invitation_type=invitation_type,
+            **kwargs,
+        )
+
+    def send_invitation(
+        self,
+        *,
+        user: PlatformUser | None = None,
+        requester_id: str | UUID | None = None,
+        requested_id: str | UUID | None = None,
+        invitation_type: Invitation.InvitationType = Invitation.InvitationType.MEMBER_TO_TEAM,
+        **kwargs,
+    ) -> HttpResponse:
+        return super().send_invitation(
+            user=self.invitee if user is None else user,
+            requester_id=self.invitee.uuid if requester_id is None else requester_id,
+            requested_id=self.team.name if requested_id is None else requested_id,
+            invitation_type=invitation_type,
+            **kwargs,
+        )
+
+    def test_member_to_team_invitation_accept(self):
+        _response, invitation = self.send_successful_invitation()
+
+        # Accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        activity_log_count = ActivityLogEntry.objects.count()
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
+        )
+
+        self.team.refresh_from_db()
+        self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_team_to_user_invitation_requested_in_url_accept(self):
+        _response, invitation = self.send_successful_invitation(
+            url=reverse(
+                'backoffice:invitation-send',
+                kwargs={
+                    'requester_id': self.invitee.uuid,
+                    'requested_id': self.team.uuid,
+                    'type': Invitation.InvitationType.MEMBER_TO_TEAM.lower(),
+                },
+            ),
+        )
+
+        # Accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        activity_log_count = ActivityLogEntry.objects.count()
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.ACCEPTED,
+        )
+
+        self.team.refresh_from_db()
+        self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_member_to_team_invitation_withdraw(self):
+        _response, invitation = self.send_successful_invitation()
+
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.WITHDRAWN,
+        )
+
+        # Test if we can send the invitation again
+        _response, invitation = self.send_successful_invitation(
+            exprected_count=2,
+        )
+
+    def test_member_to_team_invitation_reject(self):
+        with patch.object(timezone, 'now', return_value=datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)):
+            _response, invitation = self.send_successful_invitation()
+
+            self.send_action(
+                user=self.team_manager,
+                invitation=invitation,
+                action='reject',
+                expected_state=Invitation.RequestsState.REJECTED,
+            )
+
+            # Try to re-Send invitation from habitat to assembly
+            with patch('core.models.invitation._', mocktrans):
+                response = self.send_invitation(
+                    expected_code=200,
+                )
+
+            self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+
+        # After the rejection has timed out, can send invitation again
+        with (
+            patch.object(timezone, 'now', return_value=(datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC)) + timedelta(hours=4)),
+            patch('core.models.invitation.datetime') as mock_datetime,
+        ):
+            mock_datetime.now.return_value = datetime(2042, 12, 27, 12, 34, 0, 0, tzinfo=UTC) + timedelta(hours=4)
+            _response, invitation_2 = self.send_successful_invitation(
+                exprected_count=2,
+            )
+
+            # Accept invitation
+            self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+            activity_log_count = ActivityLogEntry.objects.count()
+
+            self.send_action(
+                user=self.team_manager,
+                invitation=invitation_2,
+                action='accept',
+                expected_state=Invitation.RequestsState.ACCEPTED,
+            )
+
+            self.team.refresh_from_db()
+            self.assertTrue(self.team.members.filter(user=self.invitee.id).exists())
+            self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_member_to_team_invitation_update(self):
+        _response, invitation = self.send_successful_invitation()
+
+        with patch('backoffice.views.invitations.update._', mocktrans):
+            response = self.client.get(
+                reverse('backoffice:invitation-edit', kwargs={'pk': invitation.id}),
+            )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+        self.assertContains(response, 'Invitation__introduction__team_translated')
+        self.assertContains(response, 'Invitation__type__member_to_team_translated')
+
+        response = self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            data={
+                'requester_id': invitation.requester.id,
+                'requested_id': invitation.requested.id,
+                'comment': 'Updated comment',
+            },
+        )
+        invitation.refresh_from_db()
+        self.assertEqual(invitation.comment, 'Updated comment')
+
+    def test_member_to_team_invitation_invalid_action(self):
+        _response, invitation = self.send_successful_invitation()
+
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='invalid',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=400,
+        )
+
+    def test_no_permission(self):
+        _response, invitation = self.send_successful_invitation()
+
+        # Cannot accept invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='accept',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        # Cannot reject invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        self.send_action(
+            user=self.invitee,
+            invitation=invitation,
+            action='reject',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        # Cannot withdraw invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='withdraw',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+        )
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+
+        # Cannot update invitation
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        self.send_action(
+            user=self.team_manager,
+            invitation=invitation,
+            action='update',
+            expected_state=Invitation.RequestsState.REQUESTED,
+            expected_code=403,
+            data={
+                'requester_id': invitation.requester.id,
+                'requested_id': invitation.requested.id,
+                'comment': 'Updated comment',
+            },
+        )
+
+        self.team.refresh_from_db()
+        self.assertFalse(self.team.members.filter(user=self.invitee.id).exists())
+        invitation.refresh_from_db()
+        self.assertEqual(invitation.comment, '')
+
+    def test_duplicate_invitation(self):
+        _response, invitation = self.send_successful_invitation()
+
+        # Try to send duplicate invitation
+
+        # Send invitation from habitat to assembly
+        with patch('core.models.invitation._', mocktrans):
+            response = self.send_invitation(
+                expected_code=200,
+            )
+        self.assertTemplateUsed(response, 'backoffice/invitations/create_edit.html')
+        self.assertContains(response, f'Invitation__error__already_exists {self.invitee.name} {self.team.name} {self.invitee.name}_translated')
+        self.assertEqual(Invitation.objects.count(), 1)
diff --git a/src/backoffice/tests/teams/__init__.py b/src/backoffice/tests/teams/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd1be652f674f76152c8433e382f00e26aa85e89
--- /dev/null
+++ b/src/backoffice/tests/teams/__init__.py
@@ -0,0 +1,21 @@
+from backoffice.tests.teams.members import (
+    TeamMemberDeleteViewTestCase,
+    TeamMemberUpdateViewTestCase,
+)
+from backoffice.tests.teams.teams import (
+    TeamCreateViewTestCase,
+    TeamDeleteViewTestCase,
+    TeamDetailViewTestCase,
+    TeamListViewTestCase,
+    TeamUpdateViewTestCase,
+)
+
+__all__ = (
+    'TeamCreateViewTestCase',
+    'TeamDeleteViewTestCase',
+    'TeamDetailViewTestCase',
+    'TeamListViewTestCase',
+    'TeamMemberDeleteViewTestCase',
+    'TeamMemberUpdateViewTestCase',
+    'TeamUpdateViewTestCase',
+)
diff --git a/src/backoffice/tests/teams/members.py b/src/backoffice/tests/teams/members.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea2d3313b0c62c5d3b84794ccf5c3df331732944
--- /dev/null
+++ b/src/backoffice/tests/teams/members.py
@@ -0,0 +1,202 @@
+from django.contrib.messages import ERROR
+from django.contrib.messages.storage.base import Message
+from django.contrib.messages.test import MessagesTestMixin
+from django.urls import reverse
+from django.utils.translation import activate
+from django.utils.translation import gettext as _
+
+from core.models import ActivityLogEntry, PlatformUser, Team, TeamMember
+
+from backoffice.tests.base import BackOfficeTestCase
+
+
+class TeamMemberDeleteViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.staff_2 = PlatformUser.objects.create(username='test_staff_2', is_staff=True)
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member_staff = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member_staff_2 = TeamMember.objects.create(team=self.team, user=self.staff_2, can_manage=True)
+        self.team_member_user = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_remove_member_admin(self):
+        activate('en')
+        self.client.force_login(self.admin)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+        )
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertTrue(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count)
+        self.assertContains(response, _('TeamMember__delete__warning__header'))
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertFalse(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_remove_member_staff(self):
+        activate('en')
+        self.client.force_login(self.staff)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+        )
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertTrue(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count)
+        self.assertContains(response, _('TeamMember__delete__warning__header'))
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertFalse(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_leave_member(self):
+        activate('en')
+        self.client.force_login(self.user)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+        )
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertTrue(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count)
+        self.assertContains(response, _('TeamMember__leave__warning__header'))
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertFalse(TeamMember.objects.filter(user=self.user).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+    def test_cannot_remove_last_manager(self):
+        activate('en')
+        self.client.force_login(self.admin)
+        activity_log_count = ActivityLogEntry.objects.count()
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff_2.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertFalse(TeamMember.objects.filter(user=self.staff_2).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertTrue(TeamMember.objects.filter(user=self.staff).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+        self.assertContains(response, _('TeamMember__delete__cannot_delete_last_manager'))
+
+    def test_cannot_leave_last_manager(self):
+        activate('en')
+        self.client.force_login(self.staff)
+        activity_log_count = ActivityLogEntry.objects.count()
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff_2.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertFalse(TeamMember.objects.filter(user=self.staff_2).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+        response = self.client.post(
+            reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}),
+            {'confirmation': 'true'},
+        )
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertTrue(TeamMember.objects.filter(user=self.staff).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+        self.assertContains(response, _('TeamMember__delete__cannot_delete_last_manager'))
+
+
+class TeamMemberUpdateViewTestCase(MessagesTestMixin, BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.staff_2 = PlatformUser.objects.create(username='test_staff_2', is_staff=True)
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member_staff = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member_staff_2 = TeamMember.objects.create(team=self.team, user=self.staff_2, can_manage=True)
+        self.team_member_user = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_update_member_admin(self):
+        activate('en')
+        self.client.force_login(self.admin)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.user, can_manage=True).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'false'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.staff, can_manage=False).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2)
+
+    def test_update_member_staff(self):
+        activate('en')
+        self.client.force_login(self.staff)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'true'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.user, can_manage=True).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+        # Test to remove rights from yourself
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'false'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.staff, can_manage=False).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 2)
+
+    def test_cannot_remove_last_manager(self):
+        activate('en')
+        self.client.force_login(self.staff)
+        activity_log_count = ActivityLogEntry.objects.count()
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_staff_2.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'false'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.staff_2, can_manage=False).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+
+        response = self.client.post(
+            reverse('backoffice:team-member-update', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}),
+            data={'can_manage': 'false'},
+        )
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertTrue(TeamMember.objects.filter(user=self.staff, can_manage=True).exists())
+        self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1)
+        self.assertMessages(response, [Message(ERROR, _('TeamMember__clean__cannot_remove_last_manager'))])
diff --git a/src/backoffice/tests/teams/teams.py b/src/backoffice/tests/teams/teams.py
new file mode 100644
index 0000000000000000000000000000000000000000..576965c9df517a461a7ff6ac2794f42ba883a644
--- /dev/null
+++ b/src/backoffice/tests/teams/teams.py
@@ -0,0 +1,265 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from django.utils.translation import activate
+from django.utils.translation import gettext as _
+
+from core.models import (
+    ConferenceMember,
+    PlatformUser,
+    Team,
+    TeamMember,
+)
+from core.tests.mock import mocktrans
+
+from backoffice.tests.base import BackOfficeTestCase
+
+
+class TeamListViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.teams = {}
+        for team in ['team1', 'team2', 'team3']:
+            self.teams[team] = Team.objects.create(
+                name=team,
+                conference=self.conf,
+            )
+        self.teams['team1'].require_staff = True
+        TeamMember.objects.create(team=self.teams['team1'], user=self.staff)
+        TeamMember.objects.create(team=self.teams['team1'], user=self.admin, can_manage=True)
+        TeamMember.objects.create(team=self.teams['team2'], user=self.staff, can_manage=True)
+        TeamMember.objects.create(team=self.teams['team3'], user=self.admin)
+
+    def test_team_list_unauthenticated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:teams'))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:teams'))
+
+    def test_team_list_admin(self):
+        self.client.force_login(self.admin)
+        with patch('backoffice.views.teams.teams._', mocktrans):
+            response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.all().order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertContains(response, _('Team__create__button'))
+
+    def test_team_list_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.all().order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertNotContains(response, _('Team__create__button'))
+
+    def test_team_list_user(self):
+        self.client.force_login(self.user)
+        with patch('backoffice.views.teams.teams._', mocktrans):
+            response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.filter(require_staff=False).order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertNotContains(response, _('Team__create__button'))
+
+
+class TeamDetailViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.user_2 = PlatformUser.objects.create_user(
+            username='user2',
+        )
+        self.user_2_cm = ConferenceMember.objects.create(conference=self.conf, user=self.user_2)
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member = TeamMember.objects.create(team=self.team, user=self.admin)
+        self.team_member2 = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member3 = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_team_detail_unauthenicated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+
+    def test_team_detail_admin(self):
+        self.client.force_login(self.admin)
+        response = self.client.get(reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
+        self.assertEqual(self.team, response.context['team'])
+        self.assertContains(response, _('Team__edit__button'))
+        self.assertContains(response, _('Team__delete__button'))
+        self.assertNotContains(response, _('TeamMember__join'))
+
+    def test_team_detail_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
+        self.assertEqual(self.team, response.context['team'])
+        self.assertContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
+        self.assertNotContains(response, _('TeamMember__join'))
+
+    def test_team_detail_team_member(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
+        self.assertEqual(self.team, response.context['team'])
+        self.assertNotContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
+        self.assertNotContains(response, _('TeamMember__join'))
+
+    def test_team_detail_non_team_member(self):
+        self.client.force_login(self.user_2)
+        response = self.client.get(reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
+        self.assertEqual(self.team, response.context['team'])
+        self.assertNotContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
+        self.assertContains(response, _('TeamMember__join'))
+
+
+class TeamCreateViewTestCase(BackOfficeTestCase):
+    def test_team_create_unauthenicated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:team-create'))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:team-create'))
+
+    def test_team_create_admin(self):
+        self.client.force_login(self.admin)
+        response = self.client.get(reverse('backoffice:team-create'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/create_edit.html')
+        self.assertContains(response, _('create'))
+        self.assertContains(response, _('Team__create__title'))
+
+        response = self.client.post(reverse('backoffice:team-create'), data={'name': 'team'})
+        self.assertTrue(Team.objects.filter(name='team').exists())
+        team = Team.objects.get(name='team')
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': team.uuid}))
+
+    def test_team_create_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:team-create'))
+        self.assertEqual(response.status_code, 403)
+
+    def test_team_create_team_member(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:team-create'))
+        self.assertEqual(response.status_code, 403)
+
+
+class TeamUpdateViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.user_2 = PlatformUser.objects.create_user(
+            username='user2',
+        )
+        self.user_2_cm = ConferenceMember.objects.create(conference=self.conf, user=self.user_2)
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member = TeamMember.objects.create(team=self.team, user=self.admin)
+        self.team_member2 = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member3 = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_team_update_unauthenicated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
+
+    def test_team_edit_admin(self):
+        self.client.force_login(self.admin)
+        response = self.client.get(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/create_edit.html')
+        self.assertNotContains(response, _('create'))
+        # TODO: Add test for title
+        self.assertContains(response, _('Team__edit__submit'))
+
+        response = self.client.post(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}), data={'name': 'team'})
+        self.assertTrue(Team.objects.filter(name='team').exists())
+        team = Team.objects.get(name='team')
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': team.uuid}))
+
+    def test_team_edit_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/create_edit.html')
+        self.assertNotContains(response, _('create'))
+        # TODO: Add test for title
+        self.assertContains(response, _('Team__edit__submit'))
+
+        response = self.client.post(
+            reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}),
+            data={'name': 'team', 'description_de': 'description_de', 'description_en': 'description_en'},
+        )
+        self.assertTrue(Team.objects.filter(name='team').exists())
+        self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid}))
+        self.team.refresh_from_db()
+        self.assertEqual(self.team.description_de, 'description_de')
+        self.assertEqual(self.team.description_en, 'description_en')
+
+    def test_team_edit_team_member(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 403)
+
+
+class TeamDeleteViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member = TeamMember.objects.create(team=self.team, user=self.admin)
+        self.team_member2 = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member3 = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_team_delete_unauthenicated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+
+    def test_team_delete_admin(self):
+        self.client.force_login(self.admin)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertContains(response, _('Team__delete__warning__header'))
+        self.assertContains(response, _('Team__delete__submit'))
+
+        response = self.client.post(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        # No confirmation, should not delete
+        self.assertTrue(Team.objects.filter(name='team').exists())
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertContains(response, _('Team__delete__warning__header'))
+        self.assertContains(response, _('Team__delete__submit'))
+
+        response = self.client.post(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}), data={'confirmation': 'true'})
+        self.assertFalse(Team.objects.filter(name='team').exists())
+        self.assertRedirects(response, reverse('backoffice:teams'))
+
+    def test_team_delete_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 403)
+
+    def test_team_delete_team_member(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 403)
diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py
index e118e148b8673f9655ac3f7c7156d433bf751b03..b3ceb9af6149da55edbcf45447b6301293242469 100644
--- a/src/backoffice/urls.py
+++ b/src/backoffice/urls.py
@@ -13,6 +13,7 @@ from backoffice.views import (
     profile,
     projects,
     schedules,
+    teams,
     vouchers,
     wiki,
 )
@@ -107,9 +108,11 @@ urlpatterns = [
     path('assembly/<uuid:assembly>/r/<uuid:room>/remove_link', assemblies.RoomLinkDeleteView.as_view(), name='roomlink-remove'),
     path('assembly/<uuid:assembly>/r/<uuid:room>/remove', assemblies.AssemblyRoomDeleteView.as_view(), name='assembly-remove-room'),
     path('invitations', invitations.InvitationListView.as_view(), name='invitations'),
-    path('invitation/<uuid:pk>', invitations.InvitationUpdateView.as_view(), name='invitation'),
-    path('invite/<str:type>/', invitations.InvitationSendView.as_view(), name='invitation-send'),
-    path('invite/<str:type>/<uuid:requester_id>', invitations.InvitationSendView.as_view(), name='invitation-send-with-pk'),
+    path('invitation/<uuid:pk>', invitations.InvitationDetailView.as_view(), name='invitation-detail'),
+    path('invitation/<uuid:pk>/edit', invitations.InvitationUpdateView.as_view(), name='invitation-edit'),
+    path('invitation/<uuid:pk>/decision', invitations.InvitationDecisionView.as_view(), name='invitation-decision'),
+    path('invite/<str:type>/<uuid:requester_id>', invitations.InvitationCreateView.as_view(), name='invitation-send'),
+    path('invite/<str:type>/<uuid:requester_id>/<uuid:requested_id>', invitations.InvitationCreateView.as_view(), name='invitation-send'),
     path('map/floors', FloorListView.as_view(), name='map-floor-list'),
     path('map/floor/new', FloorCreateView.as_view(), name='map-floor-create'),
     path('map/floor/<uuid:pk>', FloorUpdateView.as_view(), name='map-floor-edit'),
@@ -151,6 +154,13 @@ urlpatterns = [
     path('self-organized/sessions/<uuid:pk>/delete', events.SoSDeleteView.as_view(), name='sos-delete'),
     path('sos/new', RedirectView.as_view(pattern_name='backoffice:sos-create')),
     path('sos/<uuid:pk>/', RedirectView.as_view(pattern_name='backoffice:sos-edit')),
+    path('teams', teams.TeamListView.as_view(), name='teams'),
+    path('team/create', teams.TeamCreateView.as_view(), name='team-create'),
+    path('team/<uuid:uuid>', teams.TeamDetailView.as_view(), name='team'),
+    path('team/<uuid:uuid>/edit', teams.TeamUpdateView.as_view(), name='team-edit'),
+    path('team/<uuid:uuid>/delete', teams.TeamDeleteView.as_view(), name='team-delete'),
+    path('team/<uuid:team>/member/<uuid:pk>/delete', teams.TeamMemberDeleteView.as_view(), name='team-member-delete'),
+    path('team/<uuid:team>/member/<uuid:pk>/update', teams.TeamMemberUpdateView.as_view(), name='team-member-update'),
     path('vouchers', vouchers.VouchersView.as_view(), name='vouchers'),
     path('_boom', misc.BoomView.as_view()),
 ]
diff --git a/src/backoffice/views/assemblies/members.py b/src/backoffice/views/assemblies/members.py
index edd2866c2e43003a98651ccfbf672a175146f7a5..5b0e31a976cab65bc9023aa180341c2acd0bafef 100644
--- a/src/backoffice/views/assemblies/members.py
+++ b/src/backoffice/views/assemblies/members.py
@@ -69,6 +69,7 @@ class MemberListView(AssemblyMixin, ListView):
 
             elif k == 'show':
                 m = self.get_queryset().select_related('member', 'assembly').get(member_id=int(v))
+                # TODO: Update after deciding oh one or more conferences in #648
                 if not m.member.conferences.filter(conference_id=m.assembly.conference_id).exists():
                     messages.error(self.request, format_lazy(_('Assembly__members__no_member_cant_display'), user=m.member))
                     continue
diff --git a/src/backoffice/views/assemblyteam.py b/src/backoffice/views/assemblyteam.py
index 6e9654c63c176d8713dcf37f7e4bd3ae0af9be03..40cedbb0c1e3dcd056f2440a6b533ef096204c1c 100644
--- a/src/backoffice/views/assemblyteam.py
+++ b/src/backoffice/views/assemblyteam.py
@@ -182,9 +182,16 @@ class AssemblyView(SingleAssemblyTeamMixin, DetailView):
     template_name = 'backoffice/assemblyteam_assembly_detail.html'
 
     def get_context_data(self, *args, **kwargs):
-        ctx = super().get_context_data(*args, **kwargs)
-        ctx['latest_note'] = self.assembly.logentries.exclude(comment=None).order_by('-timestamp').first()
-        return ctx
+        if not Assembly.type_is(assembly := self.get_object()):  # pragma: no cover
+            raise ValueError('Invalid object found for AssemblyView')
+        log_entries = assembly.logentries.order_by('-timestamp')
+        return {
+            **super().get_context_data(*args, **kwargs),
+            'latest_note': log_entries.first(),
+            'all_log_entries': log_entries,
+            'msg_log_entries': log_entries.filter(comment__isnull=False),
+            'add_comment_help': _('ActivityLog__add_comment__AssemblyTeam'),
+        }
 
     def post(self, *args, **kwargs):
         comment = self.request.POST.get('comment', '').strip()
diff --git a/src/backoffice/views/auth.py b/src/backoffice/views/auth.py
index 5e0e8ff63d6ad443bdb042fd60418a1b267c3c1a..89832a316f59b36e81b87f444b6576f857a800dc 100644
--- a/src/backoffice/views/auth.py
+++ b/src/backoffice/views/auth.py
@@ -48,6 +48,7 @@ class AuthDebugView(ConferenceLoginRequiredMixin, View):
             'active': u.is_active,
             'flags': [],
             'groups': list(u.groups.values_list('name', flat=True)),
+            'teams': [f"{x['team__conference__name']}: {x['team__name']}" for x in u.teams.values('team__name', 'team__conference__name')],
             'permissions': [str(x) for x in u.get_all_permissions()],
         }
         if u.is_superuser:
diff --git a/src/backoffice/views/invitations/__init__.py b/src/backoffice/views/invitations/__init__.py
index 696ed9a8bc5185dbced1abe5769830dc7ae22f99..5bdf919176ad55b133eb0d99a78ca5c486d1b23e 100644
--- a/src/backoffice/views/invitations/__init__.py
+++ b/src/backoffice/views/invitations/__init__.py
@@ -1,50 +1,92 @@
 from typing import Any
 
-from django.http import Http404, HttpRequest
-from django.http.response import HttpResponse
-from django.views.generic import CreateView, ListView, UpdateView
+from django.db.models.query import QuerySet
+from django.urls import reverse_lazy
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import DetailView, ListView
+from rules.contrib.views import AutoPermissionRequiredMixin
 
-from core.models import Invitation
+from core.models import Assembly, Invitation
 
-from backoffice.views.invitations.habitat import InvitationHabitatSendView, InvitationHabitatUpdateView
+from backoffice.views.invitations.send import InvitationCreateView
+from backoffice.views.invitations.update import InvitationDecisionView, InvitationUpdateView
+from backoffice.views.mixins import ConferenceLoginRequiredMixin, ConferenceRuleRequiredMixin
 
 
-class InvitationListView(ListView):
-    def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
-        raise Http404('View not yet implemented')
+class InvitationListView(ConferenceLoginRequiredMixin, ListView):
+    model = Invitation
+    template_name = 'backoffice/invitations/list.html'
+    context_object_name = 'invitations'
+
+    def get_queryset(self) -> QuerySet[Invitation]:
+        return super().get_queryset().filter(state=Invitation.RequestsState.REQUESTED)
 
+    def get_context_data(self, **kwargs):
+        return {
+            **super().get_context_data(**kwargs),
+            'active_page': 'invitations',
+        }
 
-class InvitationUpdateView(UpdateView):
+
+class InvitationDetailView(ConferenceRuleRequiredMixin, AutoPermissionRequiredMixin, DetailView):
     model = Invitation
+    template_name = 'backoffice/invitations/detail.html'
+    context_object_name = 'invitation'
 
-    def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
-        invitation = self.get_object()
-        if not Invitation.type_is(invitation):  # pragma: no cover
-            raise ValueError('Invalid invitation type')
+    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+        if not Invitation.type_is(invitation := self.object):  # pragma: no cover
+            raise ValueError('Invalid object type:')
+        context = super().get_context_data(**kwargs)
+        context['active_page'] = 'invitations'
         match invitation.type:
             case Invitation.InvitationType.HABITAT:
-                return InvitationHabitatUpdateView.as_view()(request, *args, **kwargs)
-            case _:  # pragma: no cover
-                raise Http404('View not yet implemented')
+                context.update(
+                    {
+                        'title_text': _('Invitation__title__habitat'),
+                        'introduction_text': _('Invitation__introduction__habitat'),
+                        'requester_label': _('Assembly') if invitation.requester != Assembly.Hierarchy.REGULAR else _('Assembly__parent'),
+                        'requester_link': reverse_lazy('backoffice:assembly', kwargs={'pk': invitation.requester.pk}),
+                        'requested_label': _('Assembly') if invitation.requester == Assembly.Hierarchy.REGULAR else _('Assembly__parent'),
+                        'requested_link': reverse_lazy('backoffice:assembly', kwargs={'pk': invitation.requested.pk}),
+                    }
+                )
+            case Invitation.InvitationType.TEAM_TO_MEMBER:
+                from core.templatetags.hub_absolute import hub_absolute
 
+                context.update(
+                    {
+                        'title_text': _('Invitation__type__team_to_member'),
+                        'introduction_text': _('Invitation__introduction__team'),
+                        'requester_label': _('Team'),
+                        'requested_label': _('TeamMember'),
+                        'requester_link': reverse_lazy('backoffice:team', kwargs={'uuid': invitation.requester.uuid}),
+                        'requested_link': hub_absolute('plainui:user', user_slug=invitation.requested.slug),
+                    }
+                )
+            case Invitation.InvitationType.MEMBER_TO_TEAM:
+                from core.templatetags.hub_absolute import hub_absolute
 
-class InvitationSendView(CreateView):
-    model = Invitation
+                context.update(
+                    {
+                        'title_text': _('Invitation__type__member_to_team'),
+                        'introduction_text': _('Invitation__introduction__team'),
+                        'requester_label': _('TeamMember'),
+                        'requested_label': _('Team'),
+                        'requester_link': hub_absolute('plainui:user', user_slug=invitation.requester.slug),
+                        'requested_link': reverse_lazy('backoffice:team', kwargs={'uuid': invitation.requested.uuid}),
+                    }
+                )
+
+            case _:  # pragma: no cover
+                raise NotImplementedError
 
-    def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
-        match kwargs.get('type'):
-            case 'habitat':
-                return InvitationHabitatSendView.as_view()(request, *args, **kwargs)
-            case 'assembly_member':
-                raise Http404('View not yet implemented')
-            case 'team_member':
-                raise Http404('View not yet implemented')
-            case _:
-                return HttpResponse('Invalid invitation type', status=400)
+        return context
 
 
 __all__ = [
+    'InvitationCreateView',
+    'InvitationDecisionView',
+    'InvitationDetailView',
     'InvitationListView',
-    'InvitationSendView',
     'InvitationUpdateView',
 ]
diff --git a/src/backoffice/views/invitations/habitat.py b/src/backoffice/views/invitations/habitat.py
deleted file mode 100644
index 234d4a1a517aefcbbf7fabb65af66c885f2a8ef0..0000000000000000000000000000000000000000
--- a/src/backoffice/views/invitations/habitat.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from typing import Any
-
-from django.contrib.auth.views import RedirectURLMixin
-from django.contrib.messages import SUCCESS, add_message
-from django.http import HttpRequest
-from django.http.response import HttpResponse
-from django.urls import reverse
-from django.views.generic import CreateView, UpdateView, View
-from django.views.generic.edit import ModelFormMixin
-
-from core.forms import InvitationHabitatForm
-from core.models import Invitation
-
-from backoffice.views.invitations.mixins import HandleInvitationActionMixin
-from backoffice.views.mixins import ConferenceLoginRequiredMixin
-
-
-class InvitationHabitatBaseView(ConferenceLoginRequiredMixin, RedirectURLMixin, ModelFormMixin, View):
-    template_name = 'backoffice/invitations/create_edit_habitat.html'
-    form_class = InvitationHabitatForm
-    model = Invitation
-
-    def get_form_kwargs(self) -> dict[str, Any]:
-        return {
-            **super().get_form_kwargs(),
-            'conference': self.conference,
-            'user': self.request.user,
-            'requester_id': self.kwargs.get('requester_id'),
-        }
-
-    def get_default_redirect_url(self) -> str:
-        if not hasattr(self, 'object'):  # pragma: no cover
-            return reverse('backoffice:invitations')
-        return reverse('backoffice:invitation', kwargs={'pk': self.object.pk})
-
-
-class InvitationHabitatSendView(InvitationHabitatBaseView, CreateView):
-    def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
-        if kwargs.get('requester_id') is None:
-            return HttpResponse('Requester ID is required', status=400)
-        return super().dispatch(request, *args, **kwargs)
-
-    def form_valid(self, form):
-        response = super().form_valid(form)
-        add_message(self.request, SUCCESS, f'Invitation to {self.object.requested.name} sent')
-        return response
-
-
-class InvitationHabitatUpdateView(HandleInvitationActionMixin, InvitationHabitatBaseView, UpdateView):
-    object: Invitation
-
-    def has_permission(self):
-        invitation = self.get_object()
-        if not Invitation.type_is(invitation):  # pragma: no cover
-            raise ValueError('Invalid invitation type')
-        return self.request.user.has_perm('core.change_assembly', invitation.requested) or self.request.user.has_perm(
-            'core.change_assembly', invitation.requester
-        )
-
-    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
-        self.object = self.get_object()  # pylint: disable=attribute-defined-outside-init
-        if not Invitation.type_is(self.object):  # pragma: no cover
-            raise ValueError('Invalid invitation type')
-        action = request.POST.get('action', '')
-        if action in ['update', 'withdraw'] and not request.user.has_perm('core.change_assembly', self.object.requester):
-            return self.handle_no_permission()
-        if action in ['accept', 'reject'] and not request.user.has_perm('core.change_assembly', self.object.requested):
-            return self.handle_no_permission()
-        return super().post(request, *args, **kwargs)
diff --git a/src/backoffice/views/invitations/mixins.py b/src/backoffice/views/invitations/mixins.py
deleted file mode 100644
index 3d158f4c17971742b6efb63c96ebd8588c8fcbd0..0000000000000000000000000000000000000000
--- a/src/backoffice/views/invitations/mixins.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from typing import Any
-
-from django.contrib.messages import ERROR, SUCCESS, WARNING, add_message
-from django.http import HttpRequest, HttpResponseRedirect
-from django.http.response import HttpResponse
-from django.utils.translation import gettext_lazy as _
-from django.views.generic import UpdateView
-
-from core.models import Invitation
-
-
-class HandleInvitationActionMixin(UpdateView):
-    object: Invitation
-
-    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
-        action = request.POST.get('action', '')
-        if not hasattr(self, 'object') and not Invitation.type_is(self.object):  # pragma: no cover
-            raise ValueError('Invalid invitation type')
-        match action:
-            case 'update':
-                response = super().post(request, *args, **kwargs)
-                add_message(
-                    request,
-                    SUCCESS,
-                    _('Invitation__updated__message %(requester)s %(requested)s')
-                    % {
-                        'requester': self.object.requester.name,
-                        'requested': self.object.requested.name,
-                    },
-                )
-                return response
-            case 'withdraw':
-                self.object.withdraw(request.user)
-                add_message(
-                    request,
-                    WARNING,
-                    _('Invitation__withdraw__message %(requester)s %(requested)s')
-                    % {
-                        'requester': self.object.requester.name,
-                        'requested': self.object.requested.name,
-                    },
-                )
-                return HttpResponseRedirect(self.get_success_url())
-            case 'accept':
-                self.object.accept(request.user)
-                add_message(
-                    request,
-                    SUCCESS,
-                    _('Invitation__accept__message %(requester)s %(requested)s')
-                    % {
-                        'requester': self.object.requester.name,
-                        'requested': self.object.requested.name,
-                    },
-                )
-                return HttpResponseRedirect(self.get_success_url())
-            case 'reject':
-                self.object.reject(request.user)
-                add_message(
-                    request,
-                    ERROR,
-                    _('Invitation__rejected__message %(requester)s %(requested)s')
-                    % {
-                        'requester': self.object.requester.name,
-                        'requested': self.object.requested.name,
-                    },
-                )
-                return HttpResponseRedirect(self.get_success_url())
-            case _:
-                return HttpResponse('Invalid action', status=400)
diff --git a/src/backoffice/views/invitations/send.py b/src/backoffice/views/invitations/send.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b7757e250916651c9990a3fe2013089edf8cc13
--- /dev/null
+++ b/src/backoffice/views/invitations/send.py
@@ -0,0 +1,98 @@
+from typing import Any
+
+from django.contrib.auth.views import RedirectURLMixin
+from django.contrib.messages import SUCCESS, add_message
+from django.core.exceptions import BadRequest
+from django.forms.models import BaseModelForm
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView
+
+from core.forms import InvitationHabitatForm, InvitationTeamForm
+from core.models import Assembly, Invitation, PlatformUser, Team
+
+from backoffice.views.mixins import ConferenceLoginRequiredMixin
+
+
+class InvitationCreateView(ConferenceLoginRequiredMixin, RedirectURLMixin, CreateView):
+    template_name = 'backoffice/invitations/create_edit.html'
+    model = Invitation
+
+    @property
+    def invitation_type(self):
+        if (invitation_type := str(self.kwargs.get('type', '')).upper()) not in Invitation.InvitationType.values:
+            raise BadRequest('Invalid invitation type')
+        return invitation_type
+
+    def has_permission(self):
+        match self.invitation_type:
+            case Invitation.InvitationType.HABITAT:
+                assembly = get_object_or_404(Assembly, pk=self.kwargs.get('requester_id'))
+                return self.request.user.has_perm('core.change_assembly', assembly)
+            case Invitation.InvitationType.TEAM_TO_MEMBER:
+                team = get_object_or_404(Team, uuid=self.kwargs.get('requester_id'))
+                return self.request.user.has_perm('core.change_team', team)
+            case Invitation.InvitationType.MEMBER_TO_TEAM:
+                user = get_object_or_404(PlatformUser, uuid=self.kwargs.get('requester_id'))
+                return user == self.request.user
+            case _:  # pragma: no cover
+                raise NotImplementedError('Invitation type not implemented')
+
+    def get_form_class(self) -> type[BaseModelForm]:
+        match self.invitation_type:
+            case Invitation.InvitationType.HABITAT:
+                return InvitationHabitatForm
+            case Invitation.InvitationType.TEAM_TO_MEMBER | Invitation.InvitationType.MEMBER_TO_TEAM:
+                return InvitationTeamForm
+            case '_':  # pragma: no cover
+                raise NotImplementedError('Invalid invitation type')
+
+    def get_form_kwargs(self) -> dict[str, Any]:
+        kwargs = {
+            **super().get_form_kwargs(),
+            'conference': self.conference,
+            'user': self.request.user,
+            'requester_id': self.kwargs.get('requester_id'),
+            'requested_id': self.kwargs.get('requested_id'),
+        }
+        if self.invitation_type in [Invitation.InvitationType.TEAM_TO_MEMBER, Invitation.InvitationType.MEMBER_TO_TEAM]:
+            kwargs['from_team'] = self.invitation_type == Invitation.InvitationType.TEAM_TO_MEMBER
+        return kwargs
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+        add_message(self.request, SUCCESS, f'Invitation to {self.object.requested.name} sent')
+        return response
+
+    def get_default_redirect_url(self) -> str:
+        return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk})
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        match self.invitation_type:
+            case Invitation.InvitationType.HABITAT:
+                context.update(
+                    {
+                        'introduction_text': _('Invitation__introduction__habitat'),
+                        'invitation__type': _('Invitation__type__habitat'),
+                    }
+                )
+
+            case Invitation.InvitationType.TEAM_TO_MEMBER:
+                context.update(
+                    {
+                        'introduction_text': _('Invitation__introduction__team'),
+                        'invitation__type': _('Invitation__type__team_to_member'),
+                    }
+                )
+            case Invitation.InvitationType.MEMBER_TO_TEAM:
+                context.update(
+                    {
+                        'introduction_text': _('Invitation__introduction__team'),
+                        'invitation__type': _('Invitation__type__member_to_team'),
+                    }
+                )
+            case _:  # pragma: no cover
+                raise NotImplementedError('Invalid invitation type')
+        return context
diff --git a/src/backoffice/views/invitations/update.py b/src/backoffice/views/invitations/update.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8031208048689efe20af90afb9e241c7730292e
--- /dev/null
+++ b/src/backoffice/views/invitations/update.py
@@ -0,0 +1,156 @@
+from typing import Any
+
+from django.contrib.auth.views import RedirectURLMixin
+from django.contrib.messages import ERROR, SUCCESS, WARNING, add_message
+from django.core.exceptions import BadRequest
+from django.forms.models import BaseModelForm
+from django.http import HttpRequest, HttpResponseRedirect
+from django.http.response import HttpResponse
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import UpdateView, View
+from django.views.generic.detail import SingleObjectMixin
+
+from core.forms import InvitationHabitatForm, InvitationTeamForm
+from core.models import Invitation
+
+from backoffice.views.mixins import ConferenceLoginRequiredMixin, ConferenceRuleLoginRequiredMixin
+
+
+class InvitationUpdateView(ConferenceRuleLoginRequiredMixin, UpdateView):
+    model = Invitation
+    template_name = 'backoffice/invitations/create_edit.html'
+    permission_required = 'core.change_invitation'
+
+    def get_invitation(self) -> Invitation:
+        invitation = self.get_object()
+        if not Invitation.type_is(invitation):  # pragma: no cover
+            raise ValueError('Invalid object type')
+        return invitation
+
+    def get_form_class(self) -> type[BaseModelForm]:
+        invitation = self.get_invitation()
+        match invitation.type:
+            case Invitation.InvitationType.HABITAT:
+                return InvitationHabitatForm
+            case Invitation.InvitationType.TEAM_TO_MEMBER | Invitation.InvitationType.MEMBER_TO_TEAM:
+                return InvitationTeamForm
+            case _:  # pragma: no cover
+                raise NotImplementedError('Invalid invitation type')
+
+    def get_context_data(self, **kwargs):
+        invitation = self.get_invitation()
+        context = super().get_context_data(**kwargs)
+        context['active_page'] = 'invitations'
+        match invitation.type:
+            case Invitation.InvitationType.HABITAT:
+                context.update(
+                    {
+                        'introduction_text': _('Invitation__introduction__habitat'),
+                        'invitation__type': _('Invitation__type__habitat'),
+                    }
+                )
+            case Invitation.InvitationType.TEAM_TO_MEMBER:
+                context.update(
+                    {
+                        'invitation__type': _('Invitation__type__team_to_member'),
+                        'introduction_text': _('Invitation__introduction__team'),
+                    }
+                )
+            case Invitation.InvitationType.MEMBER_TO_TEAM:
+                context.update(
+                    {
+                        'invitation__type': _('Invitation__type__member_to_team'),
+                        'introduction_text': _('Invitation__introduction__team'),
+                    }
+                )
+            case _:  # pragma: no cover
+                raise NotImplementedError('Invalid invitation type')
+        return context
+
+    def get_form_kwargs(self) -> dict[str, Any]:
+        return {
+            **super().get_form_kwargs(),
+            'user': self.request.user,
+            'conference': self.conference,
+        }
+
+    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+        invitation = self.get_invitation()
+        response = super().post(request, *args, **kwargs)
+        add_message(
+            request,
+            SUCCESS,
+            _('Invitation__updated__message %(requester)s %(requested)s')
+            % {
+                'requester': invitation.requester.name,
+                'requested': invitation.requested.name,
+            },
+        )
+        return response
+
+    def get_success_url(self) -> str:
+        return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk})
+
+
+class InvitationDecisionView(ConferenceLoginRequiredMixin, RedirectURLMixin, SingleObjectMixin, View):
+    http_method_names = ['post']
+    model = Invitation
+
+    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+        self.object = self.get_object()  # pylint: disable=attribute-defined-outside-init
+        if not Invitation.type_is(invitation := self.object):  # pragma: no cover
+            raise ValueError('Invalid object type')
+        action = request.POST.get('action', '')
+        # Check permissions
+        match action:
+            case 'withdraw':
+                if not request.user.has_perm('core.change_invitation', invitation):
+                    return self.handle_no_permission()
+            case 'accept' | 'reject':
+                if not request.user.has_perm('core.accept_invitation', invitation):
+                    return self.handle_no_permission()
+            case _:
+                raise BadRequest('Invalid action')
+        match action:
+            case 'withdraw':
+                invitation.withdraw(request.user)
+                add_message(
+                    request,
+                    WARNING,
+                    _('Invitation__withdraw__message %(requester)s %(requested)s')
+                    % {
+                        'requester': invitation.requester.name,
+                        'requested': invitation.requested.name,
+                    },
+                )
+                return HttpResponseRedirect(self.get_success_url())
+            case 'accept':
+                invitation.accept(request.user)
+                add_message(
+                    request,
+                    SUCCESS,
+                    _('Invitation__accept__message %(requester)s %(requested)s')
+                    % {
+                        'requester': invitation.requester.name,
+                        'requested': invitation.requested.name,
+                    },
+                )
+                return HttpResponseRedirect(self.get_success_url())
+            case 'reject':
+                invitation.reject(request.user)
+                add_message(
+                    request,
+                    ERROR,
+                    _('Invitation__rejected__message %(requester)s %(requested)s')
+                    % {
+                        'requester': invitation.requester.name,
+                        'requested': invitation.requested.name,
+                    },
+                )
+                return HttpResponseRedirect(self.get_success_url())
+            case _:  # pragma: no cover
+                raise BadRequest('Invalid action')
+
+    def get_default_redirect_url(self) -> str:
+        return reverse('backoffice:invitation-detail', kwargs={'pk': self.object.pk})
diff --git a/src/backoffice/views/mixins.py b/src/backoffice/views/mixins.py
index 4546811fdf7ebaab17d2d93e7dbda6a04c52363d..6942730d500e8aafc80c1ee90a0236a05cafde34 100644
--- a/src/backoffice/views/mixins.py
+++ b/src/backoffice/views/mixins.py
@@ -1,10 +1,11 @@
 from django.conf import settings
 from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
 from django.core.exceptions import PermissionDenied
-from django.http import HttpRequest, HttpResponse
+from django.http import Http404, HttpRequest, HttpResponse
 from django.shortcuts import redirect
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import gettext_lazy as _
+from django.views.generic.detail import SingleObjectMixin
 from rules.contrib.views import PermissionRequiredMixin as RulesPermissionRequiredMixin
 
 from core.models.assemblies import Assembly
@@ -17,6 +18,7 @@ from core.models.sso import Application
 class ConferenceRequiredMixinBase:
     login_url = reverse_lazy('backoffice:login')
     require_conference = False
+    _conference: Conference | None = None
     _conferencemember: ConferenceMember | None = None
 
     def __init__(self, *args, **kwargs):
@@ -429,3 +431,32 @@ class PasswordMixin:
         )
 
         return context
+
+
+class SingleUUIDObjectMixin(SingleObjectMixin):
+    uuid_url_kwarg = 'uuid'
+
+    def get_object(self, queryset=None):
+        """
+        Return the object the view is displaying.
+
+        Require `self.queryset` and a `uuid` argument in the URLconf.
+        Subclasses can override this to return any object.
+        """
+        # Use a custom queryset if provided; this is required for subclasses
+        # like DateDetailView
+        if queryset is None:
+            queryset = self.get_queryset()
+
+        uuid = self.kwargs.get(self.uuid_url_kwarg)
+        if uuid is not None:
+            queryset = queryset.filter(uuid=uuid)
+        else:
+            raise AttributeError(f'Generic detail view {self.__class__.__name__} must be called with an object uuid in the URLconf.')
+
+        try:
+            # Get the single item from the filtered queryset
+            obj = queryset.get()
+        except queryset.model.DoesNotExist as exc:
+            raise Http404(_('No %(verbose_name)s found matching the query') % {'verbose_name': queryset.model._meta.verbose_name}) from exc
+        return obj
diff --git a/src/backoffice/views/moderation/users.py b/src/backoffice/views/moderation/users.py
index d4580d09a7b2130be1e3fab590309bf7aa207ce0..a2816f6c0cfec286d66eb53ed4cbf3330958e106 100644
--- a/src/backoffice/views/moderation/users.py
+++ b/src/backoffice/views/moderation/users.py
@@ -2,6 +2,7 @@ import logging
 
 from oauth2_provider.models import AccessToken
 
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.sessions.exceptions import SuspiciousSession
 from django.contrib.sessions.models import Session
@@ -58,7 +59,7 @@ class ModerationUserDetailView(ModerationAdminMixin, DetailView):
         for session in Session.objects.all():
             try:
                 session_data = session.get_decoded()
-                if session_data.get('_auth_user_id') == str(user.pk) and session_data.get('_auth_user_backend') == 'django.contrib.auth.backends.ModelBackend':
+                if session_data.get('_auth_user_id') == str(user.pk) and session_data.get('_auth_user_backend') == settings.BASE_AUTHENTICATION_BACKEND:
                     session.delete()
                     deleted_sessions += 1
 
diff --git a/src/backoffice/views/teams/__init__.py b/src/backoffice/views/teams/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bf898e257bed57fb93783d6d102d67f3670f9f7
--- /dev/null
+++ b/src/backoffice/views/teams/__init__.py
@@ -0,0 +1,21 @@
+from backoffice.views.teams.members import (
+    TeamMemberDeleteView,
+    TeamMemberUpdateView,
+)
+from backoffice.views.teams.teams import (
+    TeamCreateView,
+    TeamDeleteView,
+    TeamDetailView,
+    TeamListView,
+    TeamUpdateView,
+)
+
+__all__ = [
+    'TeamCreateView',
+    'TeamDeleteView',
+    'TeamDetailView',
+    'TeamListView',
+    'TeamMemberDeleteView',
+    'TeamMemberUpdateView',
+    'TeamUpdateView',
+]
diff --git a/src/backoffice/views/teams/members.py b/src/backoffice/views/teams/members.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc87bba2aee49d9d83c89ffa9ff0c299ba69dd2d
--- /dev/null
+++ b/src/backoffice/views/teams/members.py
@@ -0,0 +1,95 @@
+from typing import Any
+
+from django.contrib.messages import ERROR, add_message
+from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.views.generic import UpdateView
+from django.views.generic.edit import DeleteView
+from rules.contrib.views import AutoPermissionRequiredMixin
+
+from core.models import ActivityLogChange, TeamMember
+from core.models.teams.team_member import LastManagerError
+
+
+class TeamMemberUpdateView(AutoPermissionRequiredMixin, UpdateView):
+    model = TeamMember
+    http_method_names = ['post']
+    fields = ['can_manage']
+
+    def form_valid(self, form):
+        if not TeamMember.type_is(team_member := self.get_object()):  # pragma: no cover
+            raise ValueError('The object is not a TeamMember.')
+        team = team_member.team
+        old_manages = ', '.join(team.members.filter(can_manage=True).values_list('user__display_name', flat=True))
+        response = super().form_valid(form)
+        team.log_activity(
+            self.request.user,
+            manages=ActivityLogChange(old=old_manages, new=', '.join(team.members.filter(can_manage=True).values_list('user__display_name', flat=True))),
+        )
+        return response
+
+    def form_invalid(self, form):
+        for error in form.errors['__all__']:
+            add_message(self.request, ERROR, str(error))
+        return HttpResponseRedirect(self.get_success_url())
+
+    def get_success_url(self):
+        return reverse('backoffice:team', kwargs={'uuid': self.kwargs['team']})
+
+
+class TeamMemberDeleteView(AutoPermissionRequiredMixin, DeleteView):
+    model = TeamMember
+
+    template_name = 'backoffice/components/confirmation_modal.html'
+
+    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+        """
+        Delete the team member if the confirmation is confirmed.
+        """
+        if request.POST.get('confirmation', request.GET.get('confirmation', 'false')) != 'true':
+            return self.get(request, *args, **kwargs)
+        try:
+            return super().post(request, *args, **kwargs)
+        except LastManagerError:
+            add_message(request, ERROR, _('TeamMember__delete__cannot_delete_last_manager'))
+            return self.get(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        if not TeamMember.type_is(team_member := self.get_object()):  # pragma: no cover
+            raise ValueError('The object is not a TeamMember.')
+        team = team_member.team
+        old_members = ', '.join(team.members.all().values_list('user__display_name', flat=True))
+        old_count = str(team.members.count())
+        response = super().form_valid(form)
+        team.log_activity(
+            self.request.user,
+            members=ActivityLogChange(old=old_members, new=', '.join(team.members.all().values_list('user__display_name', flat=True))),
+            members_count=ActivityLogChange(old=old_count, new=str(team.members.count())),
+        )
+
+        return response
+
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        if not TeamMember.type_is(team_member := self.get_object()):  # pragma: no cover
+            raise ValueError('The object is not a TeamMember.')
+        if team_member.user == self.request.user:
+            return {
+                'confirmation_title': _('TeamMember__leave__warning__header'),
+                'confirmation_body': _('TeamMember__leave__warning__text %(team)s') % {'team': team_member.team.name},
+                'confirmation_class': 'danger',
+                'confirmation_submit': _('TeamMember__leave__submit'),
+                'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': team_member.team.uuid}),
+                **super().get_context_data(**kwargs),
+            }
+        return {
+            'confirmation_title': _('TeamMember__delete__warning__header'),
+            'confirmation_body': _('TeamMember__delete__warning__text %(team)s %(user)s') % {'team': team_member.team.name, 'user': team_member.user.username},
+            'confirmation_class': 'danger',
+            'confirmation_submit': _('TeamMember__delete__submit'),
+            'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': team_member.team.uuid}),
+            **super().get_context_data(**kwargs),
+        }
+
+    def get_success_url(self):
+        return reverse('backoffice:team', kwargs={'uuid': self.kwargs['team']})
diff --git a/src/backoffice/views/teams/teams.py b/src/backoffice/views/teams/teams.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fb6c7a49887ad50e8f0c2f6116c0c36bcbcd3e5
--- /dev/null
+++ b/src/backoffice/views/teams/teams.py
@@ -0,0 +1,162 @@
+from typing import Any
+
+from django.contrib.postgres.aggregates.general import ArrayAgg
+from django.db.models import BooleanField, Case, Count, When
+from django.db.models.query import Q, QuerySet
+from django.http import HttpRequest, HttpResponse
+from django.urls import reverse, reverse_lazy
+from django.utils.translation import gettext as _
+from django.views.generic import DetailView, FormView, ListView
+from django.views.generic.edit import CreateView, DeleteView, UpdateView
+from rules.contrib.views import AutoPermissionRequiredMixin
+
+from core.forms import TeamForm
+from core.models import ConferenceMember, Invitation, Team
+from core.views.mixins import FormMesssageMixin
+
+from backoffice.views.mixins import ConferenceRuleLoginRequiredMixin, SingleUUIDObjectMixin, guess_active_sidebar_item
+
+
+class TeamNavContextMixin(ConferenceRuleLoginRequiredMixin):
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        member = ConferenceMember.get_member(
+            conference=self.conference,
+            user=self.request.user,
+        )
+        teams = [
+            {
+                'caption': team['name'],
+                'link': reverse(
+                    'backoffice:team',
+                    kwargs={
+                        'uuid': team['uuid'],
+                    },
+                ),
+                'classes': [],
+            }
+            for team in Team.objects.all().values(
+                'uuid',
+                'name',
+                'require_staff',
+            )
+            if member.is_staff or not team['require_staff']
+        ]
+        team_entry = {
+            'caption': _('Teams'),
+            'link': reverse('backoffice:teams'),
+            'children': teams,
+            'count': len(teams),
+            'expanded': True,
+        }
+
+        context = {
+            **super().get_context_data(**kwargs),
+            'active_page': 'teams',
+            'sidebar': {
+                'title': _('Teams'),
+                'title_link': reverse('backoffice:teams'),
+                'items': [
+                    team_entry,
+                ],
+            },
+        }
+
+        guess_active_sidebar_item(self.request, context['sidebar']['items'])
+
+        return context
+
+
+class TeamListView(TeamNavContextMixin, ListView):
+    model = Team
+    template_name = 'backoffice/teams/list.html'
+    context_object_name = 'teams'
+    permission_required = 'core.view_team'
+
+    def get_queryset(self) -> QuerySet[Team]:
+        member = ConferenceMember.get_member(
+            conference=self.conference,
+            user=self.request.user,
+        )
+        qs = super().get_queryset().order_by('name')
+        if member.is_staff:
+            qs = qs.annotate(
+                members_count=Count('members'),
+            )
+        else:
+            qs = qs.filter(require_staff=False)
+        return qs.annotate(
+            member_list=ArrayAgg('members__user'),
+            self_manage_count=Count('members', filter=Q(members__user=self.request.user, members__can_manage=True)),
+        ).annotate(
+            can_manage=Case(When(self_manage_count__gt=0, then=True), default=False, output_field=BooleanField()),
+        )
+
+
+class TeamDetailView(SingleUUIDObjectMixin, TeamNavContextMixin, AutoPermissionRequiredMixin, DetailView):
+    model = Team
+    template_name = 'backoffice/teams/detail.html'
+    context_object_name = 'team'
+
+    def get_queryset(self) -> QuerySet[Team]:
+        return super().get_queryset().prefetch_related('members__user')
+
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        if not Team.type_is(team := self.object):  # pragma: no cover
+            raise ValueError('Invalid object type')
+        member_id = self.get_queryset().filter(uuid=team.uuid, members__user=self.request.user).values_list('members__id', flat=True).first()
+        log_entries = team.logentries.order_by('-timestamp')
+        return {
+            **super().get_context_data(**kwargs),
+            'member_id': member_id,
+            'received_invitations': team.received_invitations.filter(state=Invitation.RequestsState.REQUESTED),
+            'sent_invitations': team.sent_invitations.filter(state=Invitation.RequestsState.REQUESTED),
+            'all_log_entries': log_entries.all(),
+            'mst_log_entries': log_entries.filter(comment__isnull=False),
+        }
+
+
+class TeamFormMixin(AutoPermissionRequiredMixin, TeamNavContextMixin, FormMesssageMixin, FormView):
+    model = Team
+    object_name = 'team'
+    form_class = TeamForm
+    template_name = 'backoffice/teams/create_edit.html'
+
+    def get_success_url(self):
+        return reverse('backoffice:team', kwargs={'uuid': self.object.uuid})
+
+    def get_form_kwargs(self) -> dict[str, Any]:
+        return {
+            **super().get_form_kwargs(),
+            'super_user': self.request.user.is_superuser,
+            'conference': self.conference,
+        }
+
+
+class TeamCreateView(TeamFormMixin, CreateView):
+    success_message = _('Team__create__success %(name)s')
+
+
+class TeamUpdateView(SingleUUIDObjectMixin, TeamFormMixin, UpdateView):
+    success_message = _('Team__update__success %(name)s')
+
+
+class TeamDeleteView(SingleUUIDObjectMixin, FormMesssageMixin, TeamNavContextMixin, AutoPermissionRequiredMixin, DeleteView):
+    model = Team
+    template_name = 'backoffice/components/confirmation_modal.html'
+    success_url = reverse_lazy('backoffice:teams')
+    success_message = _('Team__delete__success')
+
+    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+        if request.POST.get('confirmation', request.GET.get('confirmation', 'false')) != 'true':
+            return self.get(request, *args, **kwargs)
+        return super().post(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        return {
+            **super().get_context_data(**kwargs),
+            'confirmation_title': _('Team__delete__warning__header'),
+            'confirmation_body': _('Team__delete__warning__text %(team)s') % {'team': self.object.name},
+            'confirmation_class': 'danger',
+            'confirmation_submit': _('Team__delete__submit'),
+            'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': self.object.uuid}),
+        }
diff --git a/src/core/admin.py b/src/core/admin.py
index d9ee6d63023dc4ceef23e7dcd652ed3192127f44..729f8bbaecd9b6301f3d29ecd1f40b7acd73728d 100644
--- a/src/core/admin.py
+++ b/src/core/admin.py
@@ -4,7 +4,7 @@ from typing import Any
 from django.conf import settings
 from django.contrib import admin
 from django.contrib.admin import FieldListFilter
-from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.contenttypes.admin import GenericTabularInline
 from django.contrib.gis.admin import GISModelAdmin
 from django.db.models import F, QuerySet
@@ -50,6 +50,8 @@ from .models import (
     StaticPageNamespace,
     StaticPageRevision,
     TagItem,
+    Team,
+    TeamMember,
     UserBadge,
     UserCommunicationChannel,
     UserContact,
@@ -312,6 +314,24 @@ class ConferenceTagAdmin(admin.ModelAdmin):
     search_fields = ['slug', 'description']
 
 
+class TeamMemberInline(admin.TabularInline):
+    model = TeamMember
+    extra = 1
+
+
+class TeamAdmin(GroupAdmin):
+    model = Team
+    inlines = [TeamMemberInline]
+    list_display = ['name', 'description', 'require_staff', 'member_count']
+    list_display_links = ['name']
+    list_filter = ['require_staff']
+    readonly_fields = ['description_html']
+    search_fields = ['name', 'description']
+
+    def member_count(self, obj):
+        return obj.members.count()
+
+
 class TagsInline(GenericTabularInline):
     model = TagItem
     ct_field = 'target_type'
@@ -467,7 +487,7 @@ class InvitationAdmin(admin.ModelAdmin):
     def get_fields(self, request: HttpRequest, obj: Any | None = None) -> list[str]:
         if obj is None:
             return ['requester_type', 'requester_id', 'requested_type', 'requested_id', 'comment']
-        return ['id', 'requester', 'requester_type', 'requested', 'requested_type', 'comment', 'requested_at', 'updated_at', 'type', 'sender']
+        return ['id', 'requester', 'requester_type', 'requested', 'requested_type', 'comment', 'requested_at', 'updated_at', 'type', 'state', 'sender']
 
     def get_readonly_fields(self, request: HttpRequest, obj: Any | None = None) -> list[str]:
         if obj is None:
@@ -1175,6 +1195,7 @@ admin.site.register(DereferrerStats, DereferrerStatsAdmin)
 admin.site.register(ConferenceMember, ConferenceMemberAdmin)
 admin.site.register(ConferenceTag, ConferenceTagAdmin)
 admin.site.register(ConferenceTrack, ConferenceTrackAdmin)
+admin.site.register(Team, TeamAdmin)
 admin.site.register(Assembly, AssemblyAdmin)
 admin.site.register(ActivityLogEntry, ActivityLogEntryAdmin)
 admin.site.register(BadgeCategory, BadgeCategoryAdmin)
diff --git a/src/core/backends.py b/src/core/backends.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5472ee02e3a0883f6bbebc4bd8edbd31fb9523f
--- /dev/null
+++ b/src/core/backends.py
@@ -0,0 +1,18 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+from django.contrib.auth.models import Permission
+from django.db.models import Q
+
+UserModel = get_user_model()
+
+
+class TeamsBackend(ModelBackend):
+    """
+    Authenticates against settings.AUTH_USER_MODEL.
+    """
+
+    def _get_group_permissions(self, user_obj):
+        user_groups_field = get_user_model()._meta.get_field('groups')
+        user_groups_query = f'group__{user_groups_field.related_query_name()}'
+        teams = user_obj.teams.values('team')
+        return Permission.objects.filter(Q(**{user_groups_query: user_obj}) | Q(group__in=teams))
diff --git a/src/core/forms/__init__.py b/src/core/forms/__init__.py
index f7d33eb1b1124c803a3855000949e2b10ce1f342..10852a02e1914ccb6f8d1feaa533edb96ee1d0b4 100644
--- a/src/core/forms/__init__.py
+++ b/src/core/forms/__init__.py
@@ -1,17 +1,20 @@
 from core.forms.authentication import LoginForm, PasswordResetForm, RegistrationForm
 from core.forms.conferences import ConferencePublicationForm, ConferenceRegistrationForm
-from core.forms.invitations import InvitationHabitatForm
+from core.forms.invitations import InvitationHabitatForm, InvitationTeamForm
 from core.forms.links import LinkForm, LinkFormSet
 from core.forms.projects import ProjectForm
+from core.forms.teams import TeamForm
 
 __all__ = [
     'ConferencePublicationForm',
     'ConferenceRegistrationForm',
     'InvitationHabitatForm',
+    'InvitationTeamForm',
     'LinkForm',
     'LinkFormSet',
     'LoginForm',
     'PasswordResetForm',
     'ProjectForm',
     'RegistrationForm',
+    'TeamForm',
 ]
diff --git a/src/core/forms/invitations.py b/src/core/forms/invitations.py
index 8a8560a7518f3a584dc10d2b4eea6148bba7c481..fc6225e6881ad2a3db0a83d9e26b3ef82327a296 100644
--- a/src/core/forms/invitations.py
+++ b/src/core/forms/invitations.py
@@ -1,9 +1,21 @@
+from contextlib import suppress
+from uuid import UUID
+
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db.models import QuerySet
-from django.forms import ModelForm, Select
+from django.forms import HiddenInput, ModelForm, Select
+from django.shortcuts import get_object_or_404
+from django.urls import reverse_lazy
 from django.utils.translation import gettext_lazy as _
 
-from core.models import Assembly, Conference, Invitation, PlatformUser
+from core.models import (
+    Assembly,
+    Conference,
+    Invitation,
+    PlatformUser,
+    Team,
+)
 
 
 class InvitationBaseForm(ModelForm):
@@ -16,34 +28,77 @@ class InvitationBaseForm(ModelForm):
         )
 
     requester_id: str | None = None
+    requester_link: str | None = None
+    requested_id: str | None = None
+    requested_link: str | None = None
+
+    def __init__(
+        self,
+        *args,
+        conference: Conference,
+        user: PlatformUser,
+        instance: Invitation | None,
+        requester_id: str | None = None,
+        requested_id: str | None = None,
+        **kwargs,
+    ):
+        kwargs['initial'] = kwargs.get('initial', {})
 
-    def __init__(self, *args, conference: Conference, user: PlatformUser, instance: Invitation | None, requester_id: str | None = None, **kwargs):
-        self.create = instance is None
         self.conference = conference
         self.user = user
-        if requester_id is not None:
+
+        self.create = instance is None
+        if instance is None:
+            if not requester_id:  # pragma: no cover
+                raise ValueError('Requester ID is required')
             self.requester_id = str(requester_id)
-            kwargs['initial'] = {
-                'requester_id': requester_id,
-            }
+            self.requested_id = str(requested_id) if requested_id else None
+        else:
+            self.requester_id = str(instance.requester_id)
+            self.requested_id = str(instance.requested_id)
+
+        kwargs['initial']['requester_id'] = self.requester_id
+        if 'data' in kwargs:
+            kwargs['data'] = kwargs['data'].copy()
+            kwargs['data']['requester_id'] = self.requester_id
+        if self.requested_id is not None:
+            kwargs['initial']['requested_id'] = self.requested_id
             if 'data' in kwargs:
                 kwargs['data'] = kwargs['data'].copy()
-                kwargs['data']['requester_id'] = requester_id
+                kwargs['data']['requested_id'] = self.requested_id
+
         super().__init__(*args, instance=instance, **kwargs)
-        if instance and instance.state != Invitation.RequestsState.REQUESTED:
-            del self.fields['comment']
-        if requester_id is not None:
-            self.fields['requester_id'].widget.attrs['disabled'] = True
+        # self.fields is only available after super().__init__ is called
+        self.fields['requester_id'].widget.attrs['disabled'] = True
+        if self.requested_id is not None:
+            self.fields['requested_id'].widget.attrs['disabled'] = True
+        if instance:
+            self.permissions = {
+                'requester': self.user.has_perm('core.change_invitation', self.instance),
+                'requested': self.user.has_perm('core.accept_invitation', self.instance),
+            }
 
     def save(self, commit: bool = True) -> Invitation:
         invitation = super().save(commit=False)
         if self.create:
             invitation.conference = self.conference
             invitation.sender = self.user
-        if commit:
+        if commit:  # pragma: no cover
             invitation.save()
         return invitation
 
+    def clean_requester_id(self):
+        data = self.cleaned_data['requester_id']
+        if not self.create and self.instance and str(self.instance.requester_id) != str(data):
+            raise ValidationError(_('Invitation__error__requester_change'))
+        return data
+
+    def clean_requested_id(self):
+        data = self.cleaned_data['requested_id']
+        if not self.create and self.instance and str(self.instance.requested_id) != str(data):
+            raise ValidationError(_('Invitation__error__requested_change'))
+        return data
+
 
 class InvitationHabitatForm(InvitationBaseForm):
     class Meta(InvitationBaseForm.Meta):
@@ -52,8 +107,6 @@ class InvitationHabitatForm(InvitationBaseForm):
             'requested_id': Select(),
         }
 
-    requester_id: str | None = None
-
     def get_queryset(self) -> QuerySet[Assembly]:
         return Assembly.objects.associated_with_user(conference=self.conference, user=self.user, show_public=True, staff_can_see=False)
 
@@ -66,27 +119,16 @@ class InvitationHabitatForm(InvitationBaseForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if self.create:
-            if not self.requester_id:
-                raise ValueError('Requester ID is required')
-            try:
-                assembly = self.get_queryset().get(id=self.requester_id)
-            except Assembly.DoesNotExist as exc:
-                raise ValueError('Invalid requester ID') from exc
+            assembly = get_object_or_404(Assembly, pk=self.requester_id)
             habitat = assembly.hierarchy != Assembly.Hierarchy.REGULAR
-            self.fields['requester_id'].label = _('Assembly__parent') if habitat else _('Assembly')
-            self.fields['requested_id'].label = _('Assembly') if habitat else _('Assembly__parent')
             self.fields['requester_id'].widget.choices = self.get_choices(habitat=habitat)
             self.fields['requested_id'].widget.choices = self.get_choices(habitat=(not habitat))
         else:
-            self.permissions = {
-                'requester': self.user.has_perm('core.change_assembly', self.instance.requester),
-                'requested': self.user.has_perm('core.change_assembly', self.instance.requested),
-            }
-            if not self.permissions['requester'] and 'comment' in self.fields:
-                del self.fields['comment']
             habitat = self.instance.requester.hierarchy != Assembly.Hierarchy.REGULAR
-        self.requester_label = _('Assembly__parent') if habitat else _('Assembly')
-        self.requested_label = _('Assembly__parent') if not habitat else _('Assembly')
+            self.requester_link = reverse_lazy('backoffice:assembly', kwargs={'pk': self.instance.requester.pk})
+            self.requested_link = reverse_lazy('backoffice:assembly', kwargs={'pk': self.instance.requester.pk})
+        self.fields['requester_id'].label = _('Assembly__parent') if habitat else _('Assembly')
+        self.fields['requested_id'].label = _('Assembly') if habitat else _('Assembly__parent')
 
     def save(self, commit: bool = True) -> Invitation:
         invitation = super().save(commit=False)
@@ -94,6 +136,94 @@ class InvitationHabitatForm(InvitationBaseForm):
         invitation.type = Invitation.InvitationType.HABITAT
         invitation.requester_type = assembly_type
         invitation.requested_type = assembly_type
-        if commit:
+        if commit:  # pragma: no branch
+            invitation.save()
+        return invitation
+
+
+class InvitationTeamForm(InvitationBaseForm):
+    class Meta(InvitationBaseForm.Meta):
+        widgets = {
+            'requester_id': HiddenInput(),
+        }
+
+    invitation_team: Team | None = None
+    invitation_user: PlatformUser | None = None
+
+    def __init__(self, *args, instance: Invitation | None, from_team: bool = False, **kwargs):
+        from core.templatetags.hub_absolute import hub_absolute  # pylint: disable=import-outside-toplevel
+
+        super().__init__(*args, instance=instance, **kwargs)
+        if instance:  # We are not in a create form
+            self.requester_id = str(instance.requester.uuid)
+            self.requested_id = str(instance.requested.uuid)
+            from_team = instance.requester_type.model == 'team'
+            self.invitation_team = instance.requester if from_team else instance.requested
+            self.invitation_user = instance.requested if from_team else instance.requester
+            assert self.invitation_team is not None
+            assert self.invitation_user is not None
+        elif from_team:
+            self.invitation_team = get_object_or_404(Team, uuid=self.requester_id)
+            if self.requested_id:
+                self.invitation_user = get_object_or_404(PlatformUser, uuid=self.requested_id)
+        else:
+            self.invitation_user = get_object_or_404(PlatformUser, uuid=self.requester_id)
+            if self.requested_id:
+                self.invitation_team = get_object_or_404(Team, uuid=self.requested_id)
+        self.from_team = from_team
+
+        self.fields['requester_id'].label = _('Team') if from_team else _('TeamMember')
+        self.fields['requester_id'].widget = HiddenInput()
+        self.requester_text = self.invitation_team.name if from_team else self.invitation_user.name
+        self.requester_link = (
+            reverse_lazy('backoffice:team', kwargs={'uuid': self.invitation_team.uuid})
+            if from_team
+            else hub_absolute('plainui:user', user_slug=self.invitation_user.slug)
+        )
+
+        self.fields['requested_id'].label = _('TeamMember') if from_team else _('Team')
+        if self.requested_id:
+            self.fields['requested_id'].widget = HiddenInput()
+            self.requested_text = self.invitation_user.name if from_team else self.invitation_team.name
+            self.requested_link = (
+                hub_absolute('plainui:user', user_slug=self.invitation_user.slug)
+                if from_team
+                else reverse_lazy('backoffice:team', kwargs={'uuid': self.invitation_team.uuid})
+            )
+
+    def clean_requested_id(self):
+        requested_query = self.cleaned_data['requested_id']
+
+        requested_id = None
+
+        with suppress(ValueError):
+            requested_id = get_object_or_404(PlatformUser if self.from_team else Team, uuid=UUID(requested_query, version=4)).pk
+
+        if not requested_id:
+            with suppress(ValueError):
+                requested_id = int(requested_query)
+
+        if not requested_id and self.from_team:
+            requested_id = get_object_or_404(PlatformUser, username__iexact=requested_query).pk
+        if not requested_id:
+            requested_id = get_object_or_404(Team, name__iexact=requested_query).pk
+
+        self.cleaned_data['requested_id'] = requested_id
+        return super().clean_requested_id()
+
+    def clean_requester_id(self):
+        self.cleaned_data['requester_id'] = self.invitation_team.pk if self.from_team else self.user.pk
+        return super().clean_requester_id()
+
+    def save(self, commit: bool = True) -> Invitation:
+        invitation = super().save(commit=False)
+        types = ContentType.objects.get_for_models(Team, PlatformUser)
+
+        invitation.type = Invitation.InvitationType.TEAM_TO_MEMBER if self.from_team else Invitation.InvitationType.MEMBER_TO_TEAM
+        invitation.requester_type = types[Team] if self.from_team else types[PlatformUser]
+        invitation.requested_type = types[PlatformUser] if self.from_team else types[Team]
+        if self.requested_id:
+            invitation.requested_id = get_object_or_404(PlatformUser if self.from_team else Team, uuid=self.requested_id).pk
+        if commit:  # pragma: no branch
             invitation.save()
         return invitation
diff --git a/src/core/forms/teams.py b/src/core/forms/teams.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1f91be9ee1bb9352402e47035ff3b2fb6127059
--- /dev/null
+++ b/src/core/forms/teams.py
@@ -0,0 +1,32 @@
+from django.forms import ModelForm
+from django.utils.translation import gettext_lazy as _
+
+from core.models import Team
+
+
+class TeamForm(ModelForm):
+    class Meta:
+        model = Team
+        fields = [
+            'name',
+            'description_en',
+            'description_de',
+            'require_staff',
+        ]
+        help_texts = {
+            'name': _('Team__name__help'),
+        }
+
+    def __init__(self, *args, instance: Team | None = None, conference, super_user: bool = False, **kwargs):
+        self.create = instance is None
+        self.conference = conference
+        super().__init__(*args, instance=instance, **kwargs)
+        if not super_user:
+            del self.fields['require_staff']
+
+    def save(self, commit: bool = True) -> Team:
+        team = super().save(commit=False)
+        team.conference = self.conference
+        if commit:  # pragma: no branch
+            team.save()
+        return team
diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po
index 2e70a572a7f51c92a8fad0a894681be8f63a4271..de11241f08bc95add6e9475847bb76ff6d9c95cf 100644
--- a/src/core/locale/de/LC_MESSAGES/django.po
+++ b/src/core/locale/de/LC_MESSAGES/django.po
@@ -137,9 +137,21 @@ msgstr "Der Benutzername darf nicht mit einem Unterstrich beginnen."
 msgid "Conference__is_public__unchangeable"
 msgstr "Der Konferenz Veröffentlichungsstatus kann nur mittels des Veröffentlichen Buttons durchgeführt werden."
 
+msgid "Invitation__error__requester_change"
+msgstr "Eine Einladung kann nicht nachträglich geändert werden"
+
+msgid "Invitation__error__requested_change"
+msgstr "Eine Einladung kann nicht nachträglich geändert werden"
+
 msgid "Assembly__parent"
 msgstr "Habitat"
 
+msgid "Team"
+msgstr ""
+
+msgid "TeamMember"
+msgstr "Teammitglied"
+
 msgid "Tags"
 msgstr ""
 
@@ -161,6 +173,9 @@ msgstr "Der Besitzer eines Projekts kann nicht geändert werden!"
 msgid "Project__conference__unchangeable"
 msgstr "Die Konferenz eines Projekts kann nicht geändert werden!"
 
+msgid "Team__name__help"
+msgstr "Der Name des Teams"
+
 msgid "Request failed"
 msgstr "Anfrage fehlgeschlagen"
 
@@ -1161,13 +1176,19 @@ msgstr "angenommen"
 msgid "Invitation__requests_state-rejected"
 msgstr "abgelehnt"
 
-msgid "Assembly Member"
-msgstr "Assembly Mitglied"
+msgid "Invitation__type__assembly_to_member"
+msgstr "Einladung zum Assembly Beitritt"
+
+msgid "Invitation__type__member_to_assembly"
+msgstr "Anfrage zum Assembly Beitritt"
 
-msgid "Team Member"
-msgstr "Team Mitglied"
+msgid "Invitation__type__team_to_member"
+msgstr "Einladung zum Team Beitritt"
 
-msgid "Habitat"
+msgid "Invitation__type__member_to_team"
+msgstr "Anfrage zum Team Beitritt"
+
+msgid "Invitation__type__habitat"
 msgstr "Habitatszuordnung"
 
 msgid "Invitation__name"
@@ -1966,6 +1987,48 @@ msgstr "erklärender Begleittext des Tags"
 msgid "ConferenceTag__description"
 msgstr "Beschreibung"
 
+msgid "TeamMembers"
+msgstr "Teammitglieder"
+
+msgid "TeamMember__can_manage"
+msgstr "Verwalter*in"
+
+msgid "TeamMember__can_manage__help"
+msgstr "Diese Person kann das Team verwalten, d.h. Beschreibung und Mitglieder anlegen, bearbeiten oder löschen."
+
+msgid "TeamMember__created"
+msgstr "Beigetreten"
+
+msgid "TeamMember__updated"
+msgstr "Aktualisiert"
+
+msgid "TeamMember__clean__cannot_remove_last_manager"
+msgstr "Dieses Mitglied ist das letzte mit Verwaltungsrechten, sie können nicht entfernt werden."
+
+msgid "TeamMember__delete__cannot_delete_last_manager"
+msgstr "Du kannst dieses Mitglied nicht entfernen, da es das letzte Mitglied mit Verwaltungsrechten ist."
+
+msgid "Teams"
+msgstr ""
+
+msgid "Team__conference"
+msgstr "Konferenz"
+
+msgid "Team__description"
+msgstr "Beschreibung"
+
+msgid "Team__description__help"
+msgstr "öffentliche Information (Markdown unterstützt)."
+
+msgid "Team__description_html"
+msgstr "Das gerenderte HTML der Beschreibung"
+
+msgid "Team__require_staff"
+msgstr "Nur für Konferenz-Team Mitglieder"
+
+msgid "Team__require_staff__help"
+msgstr "Dieses Team ist nur für Konferenz-Team Mitglieder zugreifbar."
+
 msgid "ConferenceMemberTicket__token_wrong_conference"
 msgstr "Das Ticket ist nicht für diese Konferenz."
 
diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po
index 9d671ff7feb9acda6418267b0a0a41ccb74c8373..bf783a463c514c21bdf23d792eb2c9488b5359fb 100644
--- a/src/core/locale/en/LC_MESSAGES/django.po
+++ b/src/core/locale/en/LC_MESSAGES/django.po
@@ -137,9 +137,21 @@ msgstr "The username must not begin with an underscore."
 msgid "Conference__is_public__unchangeable"
 msgstr "The conference publication status can only be changed using the publish button."
 
+msgid "Invitation__error__requester_change"
+msgstr "An invitation cannot be changed retrospectively"
+
+msgid "Invitation__error__requested_change"
+msgstr "An invitation cannot be changed retrospectively"
+
 msgid "Assembly__parent"
 msgstr "parent"
 
+msgid "Team"
+msgstr ""
+
+msgid "TeamMember"
+msgstr "Team member"
+
 msgid "Tags"
 msgstr ""
 
@@ -161,6 +173,9 @@ msgstr "The owner of a project cannot be changed!"
 msgid "Project__conference__unchangeable"
 msgstr "The conference of a project cannot be changed!"
 
+msgid "Team__name__help"
+msgstr "name of the team"
+
 msgid "Request failed"
 msgstr "Request failed"
 
@@ -1161,13 +1176,19 @@ msgstr "accepted"
 msgid "Invitation__requests_state-rejected"
 msgstr "rejected"
 
-msgid "Assembly Member"
-msgstr "Assembly member"
+msgid "Invitation__type__assembly_to_member"
+msgstr "Invitation to join Assembly"
 
-msgid "Team Member"
-msgstr "Team member"
+msgid "Invitation__type__member_to_assembly"
+msgstr "Request to join Assembly"
+
+msgid "Invitation__type__team_to_member"
+msgstr "Invitation to join team"
 
-msgid "Habitat"
+msgid "Invitation__type__member_to_team"
+msgstr "Request to join team"
+
+msgid "Invitation__type__habitat"
 msgstr "Habitat assignment"
 
 msgid "Invitation__name"
@@ -1964,6 +1985,48 @@ msgstr "additional explanation of this tag"
 msgid "ConferenceTag__description"
 msgstr "description"
 
+msgid "TeamMembers"
+msgstr "Team members"
+
+msgid "TeamMember__can_manage"
+msgstr "Manager"
+
+msgid "TeamMember__can_manage__help"
+msgstr "This person may manage the team, i.e. create/edit/delete description or members."
+
+msgid "TeamMember__created"
+msgstr "joined"
+
+msgid "TeamMember__updated"
+msgstr "updated"
+
+msgid "TeamMember__clean__cannot_remove_last_manager"
+msgstr "You cannot remove this members rights, as they are the last manager of the team"
+
+msgid "TeamMember__delete__cannot_delete_last_manager"
+msgstr "You cannot remove this member, as it is the last manager of the team"
+
+msgid "Teams"
+msgstr ""
+
+msgid "Team__conference"
+msgstr "Conference"
+
+msgid "Team__description"
+msgstr "description"
+
+msgid "Team__description__help"
+msgstr "public information (markdown supported)."
+
+msgid "Team__description_html"
+msgstr "the html rendered from the description"
+
+msgid "Team__require_staff"
+msgstr "require staff status"
+
+msgid "Team__require_staff__help"
+msgstr "This team is only accessible to persons with the staff status."
+
 msgid "ConferenceMemberTicket__token_wrong_conference"
 msgstr "This ticket is for another conference."
 
diff --git a/src/core/migrations/0168_alter_invitation_type.py b/src/core/migrations/0168_alter_invitation_type.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed5658286251d18139990896552354eb5ba45254
--- /dev/null
+++ b/src/core/migrations/0168_alter_invitation_type.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.1.3 on 2024-12-19 01:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0167_roomlink_add_type_nav_remove_internal_link"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="invitation",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("ASSEMBLY_TO_MEMBER", "Invitation__type__assembly_to_member"),
+                    ("MEMBER_TO_ASSEMBLY", "Invitation__type__member_to_assembly"),
+                    ("TEAM_TO_MEMBER", "Invitation__type__team_to_member"),
+                    ("MEMBER_TO_TEAM", "Invitation__type__member_to_team"),
+                    ("HABITAT", "Invitation__type__habitat"),
+                ],
+                max_length=25,
+                verbose_name="Invitation__type",
+            ),
+        ),
+    ]
diff --git a/src/core/migrations/0169_team_teammember.py b/src/core/migrations/0169_team_teammember.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0dc86a3bb537d9b0805c9249501ca7383a16fcf
--- /dev/null
+++ b/src/core/migrations/0169_team_teammember.py
@@ -0,0 +1,173 @@
+# Generated by Django 5.1.3 on 2024-12-09 01:56
+
+import core.fields
+import django.contrib.auth.models
+import django.db.models.deletion
+import rules.contrib.models
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("auth", "0012_alter_user_first_name_max_length"),
+        ("core", "0168_alter_invitation_type"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Team",
+            fields=[
+                (
+                    "group_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="auth.group",
+                    ),
+                ),
+                (
+                    "uuid",
+                    models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+                ),
+                (
+                    "description",
+                    models.TextField(
+                        blank=True,
+                        help_text="Team__description__help",
+                        verbose_name="Team__description",
+                    ),
+                ),
+                (
+                    "description_de",
+                    models.TextField(
+                        blank=True,
+                        help_text="Team__description__help",
+                        null=True,
+                        verbose_name="Team__description",
+                    ),
+                ),
+                (
+                    "description_en",
+                    models.TextField(
+                        blank=True,
+                        help_text="Team__description__help",
+                        null=True,
+                        verbose_name="Team__description",
+                    ),
+                ),
+                (
+                    "description_html",
+                    models.TextField(
+                        blank=True, null=True, verbose_name="Team__description_html"
+                    ),
+                ),
+                (
+                    "description_html_de",
+                    models.TextField(
+                        blank=True, null=True, verbose_name="Team__description_html"
+                    ),
+                ),
+                (
+                    "description_html_en",
+                    models.TextField(
+                        blank=True, null=True, verbose_name="Team__description_html"
+                    ),
+                ),
+                (
+                    "require_staff",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Team__require_staff__help",
+                        verbose_name="Team__require_staff",
+                    ),
+                ),
+                (
+                    "conference",
+                    core.fields.ConferenceReference(
+                        help_text="Conference__reference_help",
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="Teams",
+                        to="core.conference",
+                        verbose_name="Team__conference",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Team",
+                "verbose_name_plural": "Teams",
+            },
+            bases=(rules.contrib.models.RulesModelMixin, "auth.group"),
+            managers=[
+                ("objects", django.contrib.auth.models.GroupManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="TeamMember",
+            fields=[
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    "can_manage",
+                    models.BooleanField(
+                        default=False,
+                        help_text="TeamMember__can_manage__help",
+                        verbose_name="TeamMember__can_manage",
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(
+                        auto_now_add=True, verbose_name="TeamMember__created"
+                    ),
+                ),
+                (
+                    "updated",
+                    models.DateTimeField(
+                        auto_now=True, verbose_name="TeamMember__updated"
+                    ),
+                ),
+                (
+                    "team",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="members",
+                        to="core.team",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="teams",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "TeamMember",
+                "verbose_name_plural": "TeamMembers",
+            },
+            bases=(rules.contrib.models.RulesModelMixin, models.Model),
+        ),
+        migrations.AddIndex(
+            model_name="team",
+            index=models.Index(fields=["uuid"], name="core_team_uuid_4e6673_idx"),
+        ),
+        migrations.AlterUniqueTogether(
+            name="teammember",
+            unique_together={("user", "team")},
+        ),
+    ]
diff --git a/src/core/models/__init__.py b/src/core/models/__init__.py
index 7a064b14cfccba59e12b85c6b39a9744697f57ea..d51918ddfe17d6872ba448b9996b28cc47088523 100644
--- a/src/core/models/__init__.py
+++ b/src/core/models/__init__.py
@@ -1,4 +1,5 @@
 from core.models.invitation import Invitation
+from core.models.teams import Team, TeamMember
 
 from .activitylog import ActivityLogChange, ActivityLogEntry
 from .assemblies import Assembly, AssemblyLink, AssemblyMember
@@ -70,6 +71,8 @@ __all__ = [
     'StaticPageNamespace',
     'StaticPageRevision',
     'TagItem',
+    'Team',
+    'TeamMember',
     'UserBadge',
     'UserCommunicationChannel',
     'UserContact',
diff --git a/src/core/models/assemblies.py b/src/core/models/assemblies.py
index 52d93e044c02a83ccda6023c5bb2db7bf52679b5..c3497ff6e3e1404f2523368483c376ee960a82c7 100644
--- a/src/core/models/assemblies.py
+++ b/src/core/models/assemblies.py
@@ -31,6 +31,7 @@ from core.models.invitation import Invitation
 from core.models.map import MapFloor
 from core.models.tags import TaggedItemMixin, TagItem
 from core.models.users import PlatformUser
+from core.predicates import has_perms
 from core.utils import render_markdown_as_text
 from core.validators import FileSizeValidator, ImageDimensionValidator
 
@@ -56,17 +57,6 @@ def is_habitat_manager(user: PlatformUser, assembly: 'Assembly') -> bool:
     return assembly.parent.user_can_manage(user)
 
 
-def perm_is(permission: str):
-    @rules.predicate
-    def user_has_permission(user, assembly: 'Assembly'):
-        if assembly is None:
-            return False
-        member = ConferenceMember.get_member(conference=assembly.conference, user=user)
-        return member.has_perms(permission, require_all=True, require_staff=True)
-
-    return user_has_permission
-
-
 class AssemblyManager(ConferenceManagerMixin['Assembly']):
     staff_permissions = ['core.assembly_team']
     assembly_filter = 'self'
@@ -95,9 +85,9 @@ class Assembly(TaggedItemMixin, ActivityLogMixin, RulesModel):
         rules_permissions = {
             'view': is_assembly_member,
             'add': is_authenticated,
-            'change': is_assembly_manager | perm_is('core.change_assembly'),
-            'delete': is_assembly_manager | perm_is('core.delete_assembly'),
-            'leave_habitat': is_assembly_manager | is_habitat_manager | perm_is('core.change_assembly'),
+            'change': is_assembly_manager | has_perms('core.change_assembly', require_staff=True),
+            'delete': is_assembly_manager | has_perms('core.delete_assembly', require_staff=True),
+            'leave_habitat': is_assembly_manager | is_habitat_manager | has_perms('core.change_assembly', require_staff=True),
         }
 
     class State(models.TextChoices):
@@ -420,7 +410,7 @@ class Assembly(TaggedItemMixin, ActivityLogMixin, RulesModel):
             self.logger.error('Failed to create a technical user "%s" for assembly <%s> (%s): %s', username, self.slug, self.id, err)
             raise Exception('Technical user for assembly could not be created.')
 
-        cm = u.conferences.create(conference=self.conference)
+        cm = ConferenceMember(conference=self.conference, user=u)
         cm.save()
 
         self.technical_user = u
diff --git a/src/core/models/badges.py b/src/core/models/badges.py
index 1417a2d3a291a61ba4096f63a3cb73812c075261..16b07ac096233797c23ee5949668c2a7c200e6d4 100644
--- a/src/core/models/badges.py
+++ b/src/core/models/badges.py
@@ -339,6 +339,7 @@ class UserBadgeManager(models.Manager):
             defaults={
                 'accepted_by_user': not bool(issuer),
                 'token': token if token else None,
+                # TODO: Update after deciding oh one or more conferences in #648
                 'visibility': user.conferences.filter(conference=badge.conference).first().default_badge_visibility,
             },
         )
diff --git a/src/core/models/conference.py b/src/core/models/conference.py
index 02c7c5393dc9d8c22720afd1f4cd164fc07b7675..b84c50cb0048857d7e9ad66f6cc09ba484b5936f 100644
--- a/src/core/models/conference.py
+++ b/src/core/models/conference.py
@@ -62,6 +62,7 @@ class ConferenceMember(models.Model):
     is_staff = models.BooleanField(default=False)
     has_ticket = models.BooleanField(default=False, help_text=_('ConferenceMember--has_ticket--help'), verbose_name=_('ConferenceMember--has_ticket'))
 
+    # TODO: Remove this after 38c3, it will be replaced with teams
     permission_groups = models.ManyToManyField(Group, blank=True, related_name='+')
     static_page_groups = pg_fields.ArrayField(models.CharField(max_length=50), blank=True, null=True)
 
diff --git a/src/core/models/invitation.py b/src/core/models/invitation.py
index 955e1823f1e62da2a702b52dd7da3ae9099134e8..72b9e61e0006c856eb41e94af132dbd41814b33b 100644
--- a/src/core/models/invitation.py
+++ b/src/core/models/invitation.py
@@ -9,14 +9,54 @@ from django.core.exceptions import ValidationError
 from django.db import models, transaction
 from django.db.models import Max
 from django.utils.translation import gettext_lazy as _
+from rules.contrib.models import RulesModel
+from rules.predicates import predicate
 
 from core.fields import ConferenceReference
 from core.models.activitylog import ActivityLogChange
+from core.predicates import has_perms
 
 if TYPE_CHECKING:  # pragma: no cover
     from core.models.users import PlatformUser
 
 
+@predicate
+def is_requester(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool:
+    if invitation is None:  # pragma: no cover
+        return False
+    match invitation.type:
+        case Invitation.InvitationType.HABITAT:
+            return user.has_perm('core.change_assembly', invitation.requester)
+        case Invitation.InvitationType.TEAM_TO_MEMBER:
+            return user.has_perm('core.change_team', invitation.requester)
+        case Invitation.InvitationType.MEMBER_TO_TEAM:
+            return invitation.requester == user
+        case _:  # pragma: no cover
+            raise NotImplementedError
+
+
+@predicate
+def is_requested(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool:
+    if invitation is None:  # pragma: no cover
+        return False
+    match invitation.type:
+        case Invitation.InvitationType.HABITAT:
+            return user.has_perm('core.change_assembly', invitation.requested)
+        case Invitation.InvitationType.TEAM_TO_MEMBER:
+            return invitation.requested == user
+        case Invitation.InvitationType.MEMBER_TO_TEAM:
+            return user.has_perm('core.change_team', invitation.requested)
+        case _:  # pragma: no cover
+            raise NotImplementedError
+
+
+@predicate
+def state_is_requested(user: 'PlatformUser', invitation: 'Invitation | None' = None) -> bool:
+    if invitation is None:  # pragma: no cover
+        return False
+    return invitation.state == Invitation.RequestsState.REQUESTED
+
+
 class InvitationAlreadyExists(ValidationError):
     """
     This exception is raised when an invitation already exists between a requester and a requested entity.
@@ -26,7 +66,7 @@ class InvitationAlreadyExists(ValidationError):
         self.invitation = invitation
         super().__init__(
             _('Invitation__error__already_exists %(requester)s %(requested)s %(sender)s'),
-            params={'requester': invitation.requester, 'requested': invitation.requested, 'sender': invitation.sender},
+            params={'requester': invitation.requester.name, 'requested': invitation.requested.name, 'sender': invitation.sender.name},
             code='already_exists',
         )
 
@@ -70,12 +110,14 @@ class InvitationType(models.TextChoices):
         HABITAT: Invitation of ah Assembly for a habitat.
     """
 
-    ASSEMBLY_MEMBER = 'ASSEMBLY_MEMBER', _('Assembly Member')
-    TEAM_MEMBER = 'TEAM_MEMBER', _('Team Member')
-    HABITAT = 'HABITAT', _('Habitat')
+    ASSEMBLY_MEMBER = 'ASSEMBLY_TO_MEMBER', _('Invitation__type__assembly_to_member')
+    MEMBER_TO_ASSEMBLY = 'MEMBER_TO_ASSEMBLY', _('Invitation__type__member_to_assembly')
+    TEAM_TO_MEMBER = 'TEAM_TO_MEMBER', _('Invitation__type__team_to_member')
+    MEMBER_TO_TEAM = 'MEMBER_TO_TEAM', _('Invitation__type__member_to_team')
+    HABITAT = 'HABITAT', _('Invitation__type__habitat')
 
 
-class Invitation(models.Model):
+class Invitation(RulesModel):
     """
     Model representing an invitation.
 
@@ -95,6 +137,12 @@ class Invitation(models.Model):
             models.Index(fields=['requester_type', 'requester_id'], name='requester_idx'),
             models.Index(fields=['requested_type', 'requested_id'], name='requested_idx'),
         ]
+        rules_permissions = {
+            'view': has_perms('core.view_invitation', require_staff=True) | is_requester | is_requested,
+            'change': (has_perms('core.change_invitation', require_staff=True) | is_requester) & state_is_requested,
+            'accept': has_perms('core.change_invitation', require_staff=True) | is_requested,
+            'delete': has_perms('core.delete_invitation', require_staff=True),
+        }
         verbose_name = _('Invitation__name')
         verbose_name_plural = _('Invitation__name__plural')
 
@@ -131,7 +179,7 @@ class Invitation(models.Model):
     )
 
     type = models.CharField(
-        max_length=20,
+        max_length=25,
         choices=InvitationType.choices,
         verbose_name=_('Invitation__type'),
     )
@@ -187,12 +235,17 @@ class Invitation(models.Model):
         Raises:
             NotImplementedError: Raied when the invitation type is not yet implemented.
         """
-        self.decision_by = user
-        match self.type:
-            case InvitationType.HABITAT:
-                self.accept_habitat()
-            case _:
-                raise NotImplementedError
+        with transaction.atomic():
+            self.decision_by = user
+            self.state = RequestsState.ACCEPTED
+            self.save()
+            match self.type:
+                case InvitationType.HABITAT:
+                    self.accept_habitat()
+                case InvitationType.TEAM_TO_MEMBER | InvitationType.MEMBER_TO_TEAM:
+                    self.accept_team_member()
+                case _:  # pragma: no cover
+                    raise NotImplementedError
 
     def accept_habitat(self):
         """Accept the invitation for a joining a habitat.
@@ -214,21 +267,43 @@ class Invitation(models.Model):
             assembly = self.requested
         parent_change = ActivityLogChange(old=assembly.parent.name, new=habitat.name) if assembly.parent else ActivityLogChange(new=habitat.name)
         old_children = ', '.join(habitat.children.all().values_list('name', flat=True))
-        with transaction.atomic():
-            assembly.parent = habitat
-            assembly.save()
+        assembly.parent = habitat
+        assembly.save()
 
-            self.state = RequestsState.ACCEPTED
-            self.save()
+        assembly.log_activity(
+            self.decision_by,
+            parent=parent_change,
+        )
+        habitat.log_activity(
+            self.decision_by,
+            children=ActivityLogChange(old=old_children, new=', '.join(habitat.children.all().values_list('name', flat=True))),
+        )
 
-            assembly.log_activity(
-                self.decision_by,
-                parent=parent_change,
-            )
-            habitat.log_activity(
-                self.decision_by,
-                children=ActivityLogChange(old=old_children, new=', '.join(habitat.children.all().values_list('name', flat=True))),
-            )
+    def accept_team_member(self):
+        """Accept the invitation for a joining a team.
+
+        Raises:
+            ValueError: I raised when the requested or requester type is not a team.
+        """
+        from core.models import PlatformUser, Team, TeamMember  # pylint: disable=import-outside-toplevel
+
+        from_team = self.type == self.InvitationType.TEAM_TO_MEMBER
+
+        team = self.requester if from_team else self.requested
+        user = self.requested if from_team else self.requester
+
+        if not PlatformUser.type_is(user):  # pragma: no cover
+            raise ValueError('Invalid object type for User')
+        if not Team.type_is(team):  # pragma: no cover
+            raise ValueError('Invalid object type for Team')
+        old_members = ', '.join(team.members.all().values_list('user__display_name', flat=True))
+        old_count = str(team.members.count())
+        TeamMember.objects.create(team=team, user=user)
+        team.log_activity(
+            self.decision_by,
+            members=ActivityLogChange(old=old_members, new=', '.join(team.members.all().values_list('user__display_name', flat=True))),
+            members_count=ActivityLogChange(old=old_count, new=str(team.members.count())),
+        )
 
     def reject(self, user: 'PlatformUser'):
         """Reject the invitation.
diff --git a/src/core/models/teams/__init__.py b/src/core/models/teams/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a4293f441aee1aac989bfd37b5cf93387d5dd26
--- /dev/null
+++ b/src/core/models/teams/__init__.py
@@ -0,0 +1,7 @@
+from core.models.teams.team_member import TeamMember
+from core.models.teams.teams import Team
+
+__all__ = [
+    'Team',
+    'TeamMember',
+]
diff --git a/src/core/models/teams/team_member.py b/src/core/models/teams/team_member.py
new file mode 100644
index 0000000000000000000000000000000000000000..5523f45c72873bbebfaba0d7436e686875c5ee8c
--- /dev/null
+++ b/src/core/models/teams/team_member.py
@@ -0,0 +1,73 @@
+from typing import TYPE_CHECKING, Any, TypeIs
+from uuid import uuid4
+
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from rules.contrib.models import RulesModel
+from rules.predicates import is_superuser, predicate
+
+if TYPE_CHECKING:  # pragma: no cover
+    from core.models import PlatformUser
+
+
+class LastManagerError(ValidationError):
+    pass
+
+
+@predicate
+def has_invitation(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool:
+    if team_member is None:  # pragma: no cover
+        return False
+    return user.is_authenticated and team_member.team.sent_invitations.filter(requested_id=user.id).exists()
+
+
+@predicate
+def is_associated_user(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool:
+    if team_member is None:  # pragma: no cover
+        return False
+    return user.is_authenticated and team_member.user == user
+
+
+@predicate
+def is_team_manager(user: 'PlatformUser', team_member: 'TeamMember | None' = None) -> bool:
+    if team_member is None:  # pragma: no cover
+        return False
+    return user.is_authenticated and team_member.team.members.filter(user=user, can_manage=True).exists()
+
+
+class TeamMember(RulesModel):
+    class Meta:
+        rules_permissions = {
+            'add': is_team_manager | is_superuser | has_invitation,
+            'change': is_team_manager | is_superuser,
+            'delete': is_team_manager | is_superuser | is_associated_user,
+        }
+        unique_together = (('user', 'team'),)
+        verbose_name = _('TeamMember')
+        verbose_name_plural = _('TeamMembers')
+
+    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
+    user = models.ForeignKey('PlatformUser', related_name='teams', on_delete=models.CASCADE)
+    team = models.ForeignKey('Team', related_name='members', on_delete=models.CASCADE)
+    can_manage = models.BooleanField(
+        default=False,
+        verbose_name=_('TeamMember__can_manage'),
+        help_text=_('TeamMember__can_manage__help'),
+    )
+    created = models.DateTimeField(auto_now_add=True, verbose_name=_('TeamMember__created'))
+    updated = models.DateTimeField(auto_now=True, verbose_name=_('TeamMember__updated'))
+
+    @classmethod
+    def type_is(cls, obj: object) -> TypeIs['TeamMember']:
+        return isinstance(obj, cls)
+
+    def clean(self) -> None:
+        if not self.can_manage and self.team.members.filter(can_manage=True).count() == 1:
+            raise LastManagerError(_('TeamMember__clean__cannot_remove_last_manager'))
+        return super().clean()
+
+    def delete(self, using: Any = None, keep_parents: bool = False) -> tuple[int, dict[str, int]]:
+        if self.can_manage and self.team.members.filter(can_manage=True).count() == 1:
+            raise LastManagerError(_('TeamMember__delete__cannot_delete_last_manager'))
+        return super().delete(using, keep_parents)
diff --git a/src/core/models/teams/teams.py b/src/core/models/teams/teams.py
new file mode 100644
index 0000000000000000000000000000000000000000..ccd8b07cfc970a2a44999cf243ddec78cd914f97
--- /dev/null
+++ b/src/core/models/teams/teams.py
@@ -0,0 +1,111 @@
+from typing import TYPE_CHECKING, TypeIs
+from uuid import uuid4
+
+from django.contrib.auth.models import Group
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from rules.contrib.models import RulesModelBase, RulesModelMixin
+from rules.predicates import is_superuser, predicate
+
+from core.fields import ConferenceReference
+from core.markdown import compile_translated_markdown_fields, store_relationships
+from core.models.activitylog import ActivityLogEntry, ActivityLogMixin
+from core.models.invitation import Invitation
+from core.predicates import is_conference_staff
+
+if TYPE_CHECKING:  # pragma: no cover
+    from core.models import PlatformUser
+
+
+@predicate
+def is_team_member(user: 'PlatformUser', team: 'Team | None' = None) -> bool:
+    if team is None:  # pragma: no cover
+        return False
+    return user.is_authenticated and team.members.filter(user=user).exists()
+
+
+@predicate
+def is_team_manager(user: 'PlatformUser', team: 'Team | None' = None) -> bool:
+    if team is None:  # pragma: no cover
+        return False
+    return user.is_authenticated and team.members.filter(user=user, can_manage=True).exists()
+
+
+@predicate
+def is_public_team(user: 'PlatformUser', team: 'Team | None' = None) -> bool:
+    # If team is None, we are checking for the permission to view the list of teams, everybody can see it.
+    if team is None:
+        return True
+    return not team.require_staff
+
+
+class Team(RulesModelMixin, ActivityLogMixin, Group, metaclass=RulesModelBase):
+    class Meta:
+        rules_permissions = {
+            'view': is_conference_staff | is_public_team,
+            'view_details': is_team_member | is_superuser,
+            'add': is_superuser,
+            'change': is_team_manager | is_superuser,
+            'change_permissions': is_superuser,
+            'delete': is_superuser,
+            'can_join': is_conference_staff | is_public_team,
+        }
+        indexes = [
+            models.Index(fields=['uuid']),
+        ]
+        verbose_name = _('Team')
+        verbose_name_plural = _('Teams')
+
+    uuid = models.UUIDField(unique=True, default=uuid4, editable=False)
+    conference = ConferenceReference(
+        related_name='Teams',
+        verbose_name=_('Team__conference'),
+    )
+    description = models.TextField(
+        blank=True,
+        verbose_name=_('Team__description'),
+        help_text=_('Team__description__help'),
+    )
+    description_html = models.TextField(
+        blank=True,
+        null=True,
+        verbose_name=_('Team__description_html'),
+    )
+    # Is this ream only for staff members (e.g. assembly team)?
+    require_staff = models.BooleanField(
+        default=False,
+        verbose_name=_('Team__require_staff'),
+        help_text=_('Team__require_staff__help'),
+    )
+
+    sent_invitations = GenericRelation(
+        Invitation,
+        content_type_field='requester_type',
+        object_id_field='requester_id',
+        related_query_name='assemblies_inviters',
+    )
+    received_invitations = GenericRelation(
+        Invitation,
+        content_type_field='requested_type',
+        object_id_field='requested_id',
+        related_query_name='assemblies_invited',
+    )
+
+    @classmethod
+    def type_is(cls, obj: object) -> TypeIs['Team']:
+        return isinstance(obj, cls)
+
+    def save(self, *args, update_fields=None, **kwargs):
+        if update_fields is None or 'description' in update_fields:  # pragma: no branch
+            render_results = compile_translated_markdown_fields(self, self.conference, 'description')
+            store_relationships(self.conference, self, render_results)
+
+        return super().save(*args, update_fields=update_fields, **kwargs)
+
+    def get_activity_log_kind_for_user(self, user: 'PlatformUser') -> ActivityLogEntry.Kind:
+        if self.members.filter(user=user).exists():
+            return ActivityLogEntry.Kind.ENTITY
+        if user.is_superuser:
+            return ActivityLogEntry.Kind.STAFF
+        return ActivityLogEntry.Kind.OTHER
diff --git a/src/core/models/users.py b/src/core/models/users.py
index 134d8cc414d51ce4e9f022af77a32f78eea68b56..0ab6c716e25656a7d98b1e212303066f19a94872 100644
--- a/src/core/models/users.py
+++ b/src/core/models/users.py
@@ -1,9 +1,10 @@
 import logging
 import re
+from contextlib import suppress
 from pathlib import Path
 from random import choices
 from string import ascii_lowercase, digits
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, TypeIs
 from uuid import uuid4
 
 from timezone_field import TimeZoneField
@@ -212,6 +213,10 @@ class PlatformUser(AbstractUser):
 
         return AnonUser()
 
+    @classmethod
+    def type_is(cls, obj: object) -> TypeIs['PlatformUser']:
+        return isinstance(obj, cls)
+
     def get_all_verified_addresses(self):
         """Return a list of all verified addresses (for legal logging purposes, e.g. hangar creation)."""
         return self.communication_channels.filter(is_verified=True).values_list('address', flat=True)
@@ -243,8 +248,30 @@ class PlatformUser(AbstractUser):
     def is_person(self):
         return self.user_type in self.PERSON_TYPES
 
+    @cached_property
+    def name(self) -> str:
+        """
+        Shows the display name with username if they differ.
+
+        This is used to make the name available in a way matching other models (e.g. for invitations).
+
+        ReturnsL
+            str: The users display name.
+        """
+        name = self.display_name
+        if self.first_name or self.last_name:
+            name += f' <{self.username}>'
+        return name
+
     def get_display_name(self):
-        result = self.username if self.user_type != self.Type.SPEAKER else (self.first_name + ' ' + self.last_name).strip()
+        result = ''
+        if self.first_name:
+            result += self.first_name
+        if self.last_name:
+            result += f' {self.last_name}'
+        result = result.strip()
+        if not result:
+            result = self.username
         if self.pronouns:
             result += f' ({self.pronouns})'
         return result
@@ -344,6 +371,8 @@ class PlatformUser(AbstractUser):
 
         # update the display name
         self.display_name = self.get_display_name()
+        with suppress(AttributeError):
+            del self.name  # delete the cached property to force a refresh
 
         return super().save(*args, update_fields=update_fields, **kwargs)
 
diff --git a/src/core/predicates.py b/src/core/predicates.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e605f879f9ad56784b0d4e907c2c1833d400050
--- /dev/null
+++ b/src/core/predicates.py
@@ -0,0 +1,31 @@
+from typing import TYPE_CHECKING, Callable
+
+from rules.predicates import predicate
+
+if TYPE_CHECKING:
+    from src.core.models import PlatformUser
+
+
+def has_perms(
+    *required_permissions: str,
+    require_all: bool = True,
+    require_staff: bool = False,
+) -> Callable[['PlatformUser'], bool]:
+    @predicate
+    def test_team_perms(user: 'PlatformUser') -> bool:
+        # TODO: Update after deciding oh one or more conferences in #648
+        conference_member = user.conferences.first()
+        if conference_member is None:
+            return False
+        return conference_member.has_perms(*required_permissions, require_all=require_all, require_staff=require_staff)
+
+    return test_team_perms
+
+
+@predicate
+def is_conference_staff(user: 'PlatformUser') -> bool:
+    # TODO: Update after deciding oh one or more conferences in #648
+    conference_member = user.conferences.first()
+    if conference_member is None:
+        return False
+    return conference_member.is_authenticated and conference_member.is_staff
diff --git a/src/core/tests/users.py b/src/core/tests/users.py
index 8f7d86b228aba0fc438f70386b91d574d5547b1c..60f308c2916dcdb41777a1c4f2ae17d19f967977 100644
--- a/src/core/tests/users.py
+++ b/src/core/tests/users.py
@@ -163,3 +163,34 @@ class UserRegistrationTests(TestCase):
         self.assertEqual(response.status_code, 302)
         user = PlatformUser.objects.get(username=username)
         self.assertTrue(len(user.slug) > 0)
+
+
+class UserNameTests(TestCase):
+    def setUp(self) -> None:
+        self.user = PlatformUser(username='bernd')
+        self.user.save()
+
+    def test_name_output(self):
+        self.assertEqual(self.user.display_name, 'bernd')
+        self.assertEqual(self.user.name, 'bernd')
+
+        self.user.first_name = 'Bernd'
+        self.user.save()
+        self.user.refresh_from_db()
+
+        self.assertEqual(self.user.display_name, 'Bernd')
+        self.assertEqual(self.user.name, 'Bernd <bernd>')
+
+        self.user.last_name = 'Brot'
+        self.user.save()
+        self.user.refresh_from_db()
+
+        self.assertEqual(self.user.display_name, 'Bernd Brot')
+        self.assertEqual(self.user.name, 'Bernd Brot <bernd>')
+
+        self.user.pronouns = 'das'
+        self.user.save()
+        self.user.refresh_from_db()
+
+        self.assertEqual(self.user.display_name, 'Bernd Brot (das)')
+        self.assertEqual(self.user.name, 'Bernd Brot (das) <bernd>')
diff --git a/src/core/translation.py b/src/core/translation.py
index 50d03e2324dae84738131c4f73a41a914c425ed5..fff800551ecfd179321550e6a5234c14f80b33a0 100644
--- a/src/core/translation.py
+++ b/src/core/translation.py
@@ -2,7 +2,12 @@ from django.contrib import admin
 from modeltranslation.admin import TranslationAdmin
 from modeltranslation.translator import TranslationOptions, register
 
-from core.admin import BadgeAdmin, BadgeCategoryAdmin, ProjectAdmin
+from core.admin import (
+    BadgeAdmin,
+    BadgeCategoryAdmin,
+    ProjectAdmin,
+    TeamAdmin,
+)
 from core.models import (
     Assembly,
     Badge,
@@ -16,6 +21,7 @@ from core.models import (
     MetaNavItem,
     Project,
     Room,
+    Team,
 )
 
 
@@ -92,6 +98,22 @@ class ConferenceMemberTranslationOptions(TranslationOptions):
     )
 
 
+@register(Team)
+class TeamTranslationOptions(TranslationOptions):
+    fields = (
+        'description',
+        'description_html',
+    )
+
+
+class TranslatedTeamAdmin(TeamAdmin, TranslationAdmin):
+    pass
+
+
+admin.site.unregister(Team)
+admin.site.register(Team, TranslatedTeamAdmin)
+
+
 @register(Room)
 class RoomTranslationOptions(TranslationOptions):
     fields = (
diff --git a/src/core/views/mixins.py b/src/core/views/mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8b2eb5377bf0989a334150366950ef624e80880
--- /dev/null
+++ b/src/core/views/mixins.py
@@ -0,0 +1,66 @@
+from typing import Any
+
+from django.contrib.messages import ERROR, SUCCESS, add_message
+from django.forms import Form
+from django.http import HttpResponse
+
+
+class FormMesssageMixin:
+    """
+    Add a success message on successful form submission.
+    """
+
+    success_message = ''
+    success_message_code = SUCCESS
+    failure_message = ''
+    failure_message_code = ERROR
+
+    def form_valid(self, form: Form) -> HttpResponse:
+        """Set a success message on successful form submission.
+
+        Args:
+            form (Form): The form that was submitted.
+
+        Returns:
+            HTTPResponse: The response generated by the parent class.
+        """
+        response = super().form_valid(form)
+        success_message = self.get_form_message(self.success_message, form.cleaned_data)
+        if success_message:
+            add_message(
+                self.request,
+                self.success_message_code,
+                success_message,
+            )
+        return response
+
+    def form_invalid(self, form):
+        """Set a failure message on failed form submission.
+
+        Args:
+            form (Form): The form that was submitted.
+
+        Returns:
+            HTTPResponse: The response generated by the parent class.
+        """
+        response = super().form_invalid(form)
+        failure_message = self.get_form_message(self.failure_message, form.cleaned_data)
+        if failure_message:
+            add_message(
+                self.request,
+                self.failure_message_code,
+                self.get_form_message(self.failure_message, form.cleaned_data),
+            )
+        return response
+
+    def get_form_message(self, message: str, cleaned_data: dict[str, Any]) -> str:
+        """Try to generate a message from the message template.
+
+        Args:
+            message (str): The message template.
+            cleaned_data (dict[str, Any]): The cleaned data from the form.
+
+        Returns:
+            str: The formatted message.
+        """
+        return message % cleaned_data
diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py
index 087ef7e96c24c12b9076da56f62d5c4532c78f00..cfffd03078a3b9eec9a71da02cd80475386c784a 100644
--- a/src/hub/settings/base.py
+++ b/src/hub/settings/base.py
@@ -270,10 +270,11 @@ AUTH_PASSWORD_VALIDATORS = [
     },
 ]
 
+BASE_AUTHENTICATION_BACKEND = 'core.backends.TeamsBackend'
 AUTHENTICATION_BACKENDS = (
     'rules.permissions.ObjectPermissionBackend',
     'oauth2_provider.backends.OAuth2Backend',
-    'django.contrib.auth.backends.ModelBackend',
+    BASE_AUTHENTICATION_BACKEND,
 )
 
 # Session Cookie configuration
diff --git a/src/hub/settings/test.py b/src/hub/settings/test.py
index f2333e6282e79745e14c91decd10497ddc97672d..8fde833fecf917dd67d1df5c5976a16a63ef3a22 100644
--- a/src/hub/settings/test.py
+++ b/src/hub/settings/test.py
@@ -1,7 +1,7 @@
 import os
 
 os.environ.setdefault('DJANGO_SECRET_KEY', 'Testing101')
-
+os.environ.setdefault('SERVE_ADMIN', 'yes')
 from .default import *  # noqa: F401, E402, F403 # pylint: disable=wildcard-import,unused-wildcard-import
 
 INTEGRATIONS_BBB = True
diff --git a/src/plainui/jinja2/plainui/user.html.j2 b/src/plainui/jinja2/plainui/user.html.j2
index 6f8f97ea3062c238e9159a5a7a5534ad80d6181f..32c954191bb1da131ad820aafac90f83918e3bf3 100644
--- a/src/plainui/jinja2/plainui/user.html.j2
+++ b/src/plainui/jinja2/plainui/user.html.j2
@@ -14,7 +14,7 @@
 {% endblock title %}
 {% block content %}
 
-  {{ navMacro.top_nav(_("User Profile") ) }}
+  {{ navMacro.top_nav(_("User Profile %(username)s", username=display_user.username) ) }}
 
   <div class="hub-vlayout">
     <div class="hub-row">
diff --git a/src/plainui/locale/de/LC_MESSAGES/django.po b/src/plainui/locale/de/LC_MESSAGES/django.po
index 4d7fa38288aa558ebd095fd0fff06635de9b0d20..2d02369dfcd82ff74c89588de6c4b667f5a2848e 100644
--- a/src/plainui/locale/de/LC_MESSAGES/django.po
+++ b/src/plainui/locale/de/LC_MESSAGES/django.po
@@ -1087,5 +1087,6 @@ msgstr "Kommende Events"
 msgid "%(conf)s - User %(name)s"
 msgstr ""
 
-msgid "User Profile"
-msgstr "Profil"
+#, python-format
+msgid "User Profile %(username)s"
+msgstr "Profil: %(username)s"
diff --git a/src/plainui/locale/en/LC_MESSAGES/django.po b/src/plainui/locale/en/LC_MESSAGES/django.po
index 9471456f32cfeff75332c7b02c8f442ffdf83014..3e63a2246b0737894c1febf00fd4f8c91d4a45b1 100644
--- a/src/plainui/locale/en/LC_MESSAGES/django.po
+++ b/src/plainui/locale/en/LC_MESSAGES/django.po
@@ -1087,5 +1087,6 @@ msgstr ""
 msgid "%(conf)s - User %(name)s"
 msgstr ""
 
-msgid "User Profile"
-msgstr ""
+#, python-format
+msgid "User Profile %(username)s"
+msgstr "User Profile %(username)s"
diff --git a/src/plainui/views/ticket_tokens.py b/src/plainui/views/ticket_tokens.py
index 648c02d5b52589a9f6ec4636f242b8674d8b4a5c..ee824d9b59a615baf2a41ddb60057329633a8c00 100644
--- a/src/plainui/views/ticket_tokens.py
+++ b/src/plainui/views/ticket_tokens.py
@@ -7,6 +7,7 @@ __all__ = (
 
 from django_ratelimit.decorators import ratelimit
 
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth import login
 from django.contrib.auth import views as auth_views
@@ -143,7 +144,7 @@ class RedeemTokenUserCreateView(ConferenceRequiredMixin, FormView):
                 user = form.save()
                 ConferenceMemberTicket.redeem_pretix_ticket(self.conf, user, form.cleaned_data['token'])
                 ConferenceMember.objects.update_or_create(conference=self.conf, user=user, defaults={'has_ticket': True})
-                login(self.request, user, backend='django.contrib.auth.backends.ModelBackend')
+                login(self.request, user, backend=settings.BASE_AUTHENTICATION_BACKEND)
                 return HttpResponseRedirect(self.get_success_url())
         except TicketValidationError as e:
             form.add_error(None, str(e))
diff --git a/src/plainui/views/user_profile.py b/src/plainui/views/user_profile.py
index 12625588bb0fb37fa47823a7e7e521781eeb1585..2ba4f6639f95afdd467d5096e9c27499613882b2 100644
--- a/src/plainui/views/user_profile.py
+++ b/src/plainui/views/user_profile.py
@@ -63,6 +63,7 @@ class ProfileView(ConferenceRequiredMixin, UpdateView):
 
         form2_kwargs = super().get_form_kwargs()
         if hasattr(self, 'object'):
+            # TODO: Update after deciding oh one or more conferences in #648
             cm = self.object.conferences.filter(conference=self.conf).first()
             if cm:
                 form2_kwargs['instance'] = cm
@@ -119,6 +120,7 @@ class ProfileView(ConferenceRequiredMixin, UpdateView):
         form1.instance.timezone = form1.cleaned_data['timezone']
         form1.instance.save()
 
+        # TODO: Update after deciding oh one or more conferences in #648
         cm = form1.instance.conferences.filter(conference=self.conf).first()
         if cm:
             for lang in AVAILABLE_LANGUAGES:
diff --git a/src/plainui/views/users.py b/src/plainui/views/users.py
index 785e052416e2cc1e1ba9bac9141ec256702e2f74..a040f4b48e7e7417b58bd5d70394d3922ec04c36 100644
--- a/src/plainui/views/users.py
+++ b/src/plainui/views/users.py
@@ -33,6 +33,7 @@ class UserView(ConferenceRequiredMixin, TemplateView):
         context = super().get_context_data(**kwargs)
         context['conf'] = self.conf
         context['display_user'] = display_user = get_object_or_404(PlatformUser.objects.filter(slug=user_slug))
+        # TODO: Update after deciding oh one or more conferences in #648
         conference_member = display_user.conferences.filter(conference=self.conf).first()
         context['description_html'] = None if not conference_member else conference_member.description_html
         context['owned_badges'] = owned_badges
diff --git a/src/tox.ini b/src/tox.ini
index 10d80fdaedca925c33e28ca7975bebbc287a4065..fcee93d6f8cf0e512d552835dcba9ba1ff1840b1 100644
--- a/src/tox.ini
+++ b/src/tox.ini
@@ -23,4 +23,5 @@ data_file = ../.tools/coverage/coverage
 dynamic_context = test_function
 omit =
     */migrations/*
+    */tests/*
 branch=true
diff --git a/tox.ini b/tox.ini
index aeb1e3551bdea50cc1296466101ae0607e3f34a1..a0a9e0653a9335232e5d6d92ead4be029d2d43b9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -84,6 +84,7 @@ data_file = .tools/coverage/coverage
 dynamic_context = test_function
 omit =
     */migrations/*
+    */tests/*
 branch=true
 
 [coverage:report]