diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po
index c6356e8bfa7ce7626b78581c5c1f646cf56f67b5..68c1394bb21d2ec34bd6696e897eea7f15a929c9 100644
--- a/src/backoffice/locale/de/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/de/LC_MESSAGES/django.po
@@ -98,43 +98,6 @@ msgstr "Private FTP-Zugang um Dateien im Hangar abzulegen"
 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."
 
@@ -389,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"
 
@@ -801,6 +770,10 @@ msgstr "Wiki"
 msgid "nav_schedules"
 msgstr "Schedules"
 
+# use translation from core
+msgid "Invitations"
+msgstr ""
+
 msgid "Conference__Selection"
 msgstr "Konferenz auswählen"
 
@@ -843,6 +816,25 @@ msgstr "Speichern"
 msgid "ActivityLog_no_entries"
 msgstr "Keine Activity Log Einträge"
 
+#, 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"
 
@@ -952,20 +944,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__action__submit"
+msgstr "Einladung senden"
 
-msgid "Invitation__Habitat__title"
-msgstr "Einladung zum Habitatsbeitritt"
-
-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"
@@ -1903,6 +1892,16 @@ 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__habitat"
+msgstr ""
+
 #, python-format
 msgid "Invitation__updated__message %(requester)s %(requested)s"
 msgstr "Die Einladung von \"%(requester)s\" an \"%(requested)s\" ist aktualisiert worden"
diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po
index 5d4a4a19560b9bf022492fc1c350263d213b93ca..1d48e35e430fd81c4d66d7cb3a56897d19109fb9 100644
--- a/src/backoffice/locale/en/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/en/LC_MESSAGES/django.po
@@ -98,43 +98,6 @@ msgstr "your private ftp access to put files on your hangar"
 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."
 
@@ -389,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"
 
@@ -801,6 +770,10 @@ msgstr "Wiki"
 msgid "nav_schedules"
 msgstr "Schedules"
 
+# use translation from core
+msgid "Invitations"
+msgstr ""
+
 msgid "Conference__Selection"
 msgstr "Select Conference"
 
@@ -843,6 +816,25 @@ msgstr "save"
 msgid "ActivityLog_no_entries"
 msgstr "No activity log entries"
 
+#, 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 ""
 
@@ -953,21 +945,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"
 
@@ -1908,6 +1897,16 @@ 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__habitat"
+msgstr ""
+
 #, python-format
 msgid "Invitation__updated__message %(requester)s %(requested)s"
 msgstr "The invitation from \"%(requester)s\" to \"%(requested)s\" has been updated"
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/base.html b/src/backoffice/templates/backoffice/base.html
index ac043b9518d8220c396d5a05acca341ff2ed31d4..bc7ac4bce8883bbec2435707e06886584895d1c2 100644
--- a/src/backoffice/templates/backoffice/base.html
+++ b/src/backoffice/templates/backoffice/base.html
@@ -120,6 +120,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/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/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..0e8342b0bdb62704e5337260d1b9a93fe5daca11
--- /dev/null
+++ b/src/backoffice/templates/backoffice/invitations/create_edit.html
@@ -0,0 +1,182 @@
+{% 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">{% 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-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/tests/invitations/__init__.py b/src/backoffice/tests/invitations/__init__.py
index da1a5a862123ed42f03f9264930204291de78b19..0909f1390b0d12de005841cf638bc3a40ca587ff 100644
--- a/src/backoffice/tests/invitations/__init__.py
+++ b/src/backoffice/tests/invitations/__init__.py
@@ -1,18 +1,111 @@
+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
 
 
 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',
 ]
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/urls.py b/src/backoffice/urls.py
index e118e148b8673f9655ac3f7c7156d433bf751b03..accf9e23b62054ab5940daa6dd03a69365148c0e 100644
--- a/src/backoffice/urls.py
+++ b/src/backoffice/urls.py
@@ -107,9 +107,10 @@ 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('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'),
diff --git a/src/backoffice/views/invitations/__init__.py b/src/backoffice/views/invitations/__init__.py
index 696ed9a8bc5185dbced1abe5769830dc7ae22f99..379de5b38f70e751adc80ae96a81a59b49e3600f 100644
--- a/src/backoffice/views/invitations/__init__.py
+++ b/src/backoffice/views/invitations/__init__.py
@@ -1,50 +1,65 @@
 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)
+                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 _:  # pragma: no cover
-                raise Http404('View not yet implemented')
-
-
-class InvitationSendView(CreateView):
-    model = Invitation
+                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..65b30cb29a8a11aa082f7d48ea56c2df611afac9
--- /dev/null
+++ b/src/backoffice/views/invitations/send.py
@@ -0,0 +1,71 @@
+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
+from core.models import Assembly, Invitation
+
+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 _:  # 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 _:  # pragma: no cover
+                raise NotImplementedError('Invalid invitation type')
+
+    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 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 _:  # 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..8cf975a2184a2a01e1f20a99bb7095772621d9d9
--- /dev/null
+++ b/src/backoffice/views/invitations/update.py
@@ -0,0 +1,140 @@
+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
+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 _:  # 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 _:  # 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/core/admin.py b/src/core/admin.py
index d9ee6d63023dc4ceef23e7dcd652ed3192127f44..ca17a5ae5ded567cd7ddc7515dd428db6915ee88 100644
--- a/src/core/admin.py
+++ b/src/core/admin.py
@@ -467,7 +467,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:
diff --git a/src/core/forms/invitations.py b/src/core/forms/invitations.py
index 8a8560a7518f3a584dc10d2b4eea6148bba7c481..df5d51ac242b6aaa04b61c71357c3d9b8cb3e65f 100644
--- a/src/core/forms/invitations.py
+++ b/src/core/forms/invitations.py
@@ -1,6 +1,9 @@
 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.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
@@ -16,34 +19,65 @@ class InvitationBaseForm(ModelForm):
         )
 
     requester_id: str | None = None
+    requester_link: str | None = None
+    requested_link: str | None = None
+
+    def __init__(
+        self,
+        *args,
+        conference: Conference,
+        user: PlatformUser,
+        instance: Invitation | None,
+        requester_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,
-            }
-            if 'data' in kwargs:
-                kwargs['data'] = kwargs['data'].copy()
-                kwargs['data']['requester_id'] = requester_id
+        else:
+            self.requester_id = str(instance.requester_id)
+
+        kwargs['initial']['requester_id'] = self.requester_id
+        if 'data' in kwargs:
+            kwargs['data'] = kwargs['data'].copy()
+            kwargs['data']['requester_id'] = self.requester_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['requester_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):
@@ -66,27 +100,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 +117,6 @@ 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
diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po
index 5a321506f61299e167a4741fdac614935b3dcab1..511972e98c88d8be5bc60d296080f0b4878a5418 100644
--- a/src/core/locale/de/LC_MESSAGES/django.po
+++ b/src/core/locale/de/LC_MESSAGES/django.po
@@ -137,6 +137,12 @@ 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"
 
@@ -1161,13 +1167,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 "Invitation__type__team_to_member"
+msgstr "Einladung zum Team Beitritt"
 
-msgid "Team Member"
-msgstr "Team Mitglied"
+msgid "Invitation__type__member_to_team"
+msgstr "Anfrage zum Team Beitritt"
 
-msgid "Habitat"
+msgid "Invitation__type__habitat"
 msgstr "Habitatszuordnung"
 
 msgid "Invitation__name"
diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po
index 9d671ff7feb9acda6418267b0a0a41ccb74c8373..5029d87d03aa6c56f0ae627a436d83cec6ea297b 100644
--- a/src/core/locale/en/LC_MESSAGES/django.po
+++ b/src/core/locale/en/LC_MESSAGES/django.po
@@ -137,6 +137,12 @@ 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"
 
@@ -1161,13 +1167,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 "Invitation__type__member_to_assembly"
+msgstr "Request to join Assembly"
+
+msgid "Invitation__type__team_to_member"
+msgstr "Invitation to join team"
 
-msgid "Team Member"
-msgstr "Team member"
+msgid "Invitation__type__member_to_team"
+msgstr "Request to join team"
 
-msgid "Habitat"
+msgid "Invitation__type__habitat"
 msgstr "Habitat assignment"
 
 msgid "Invitation__name"
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/models/invitation.py b/src/core/models/invitation.py
index 955e1823f1e62da2a702b52dd7da3ae9099134e8..0f57555e0d088c657ae9cdf019a1677e94e6a6c9 100644
--- a/src/core/models/invitation.py
+++ b/src/core/models/invitation.py
@@ -9,14 +9,46 @@ 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 _:  # 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 _:  # 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 +58,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 +102,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 +129,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 +171,7 @@ class Invitation(models.Model):
     )
 
     type = models.CharField(
-        max_length=20,
+        max_length=25,
         choices=InvitationType.choices,
         verbose_name=_('Invitation__type'),
     )
@@ -187,12 +227,15 @@ 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 _:  # pragma: no cover
+                    raise NotImplementedError
 
     def accept_habitat(self):
         """Accept the invitation for a joining a habitat.
@@ -214,21 +257,17 @@ 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()
-
-            self.state = RequestsState.ACCEPTED
-            self.save()
+        assembly.parent = habitat
+        assembly.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 reject(self, user: 'PlatformUser'):
         """Reject the invitation.