diff --git a/aleksis/apps/matrix/example_data.yaml b/aleksis/apps/matrix/example_data.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..9fa7bee01d298979955fc7285411528ed630746b
--- /dev/null
+++ b/aleksis/apps/matrix/example_data.yaml
@@ -0,0 +1,254 @@
+# All passwords are "admin"
+- model: auth.user
+  pk: 2
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test1
+    is_active: true
+- model: auth.user
+  pk: 3
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test2
+    is_active: true
+- model: auth.user
+  pk: 4
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test3
+    is_active: true
+- model: auth.user
+  pk: 5
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test4
+    is_active: true
+- model: auth.user
+  pk: 6
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test5
+    is_active: true
+- model: auth.user
+  pk: 7
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test6
+    is_active: true
+- model: auth.user
+  pk: 8
+  fields:
+    password: pbkdf2_sha256$260000$FaCoGiW0LEmDlf5NozIIPw$FMtYfHkuRzOXXnD6xEnlJUa0j1HEwI1OpQcH5qkk+LE=
+    username: test7
+    is_active: true
+- model: sites.site
+  pk: 1
+  fields:
+    domain: example.org
+    name: example.org
+- model: core.person
+  pk: 2
+  fields:
+    site: 1
+    user: 2
+    is_active: true
+    first_name: Jane
+    last_name: Doe
+    short_name: DOE
+    email: jane.doe@example.com
+- model: core.person
+  pk: 3
+  fields:
+    site: 1
+    user: 3
+    is_active: true
+    first_name: Aunt
+    last_name: Dawn
+    short_name: DAW
+    email: aunt.dawn@example.com
+- model: core.person
+  pk: 4
+  fields:
+    site: 1
+    user: 4
+    is_active: true
+    first_name: Mad
+    last_name: Wilson
+    short_name: WIL
+    email: mad.wilson@example.com
+- model: core.person
+  pk: 5
+  fields:
+    site: 1
+    user: 5
+    is_active: true
+    first_name: Lara
+    last_name: God
+    short_name: GOD
+    email: lara.god@example.com
+- model: core.person
+  pk: 6
+  fields:
+    site: 1
+    user: 6
+    is_active: true
+    first_name: Student
+    last_name: A
+    email: student.a@example.com
+- model: core.person
+  pk: 7
+  fields:
+    site: 1
+    user: 7
+    is_active: true
+    first_name: Student
+    last_name: B
+    email: student.b@example.com
+- model: core.person
+  pk: 8
+  fields:
+    site: 1
+    user: 8
+    is_active: true
+    first_name: Student
+    last_name: C
+    email: student.C@example.com
+
+# Classes: PK 100 and above
+
+- model: core.group
+  pk: 101
+  fields:
+    site: 1
+    name: "8c"
+    short_name: "8c"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 102
+  fields:
+    site: 1
+    name: "5c"
+    short_name: "5c"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 103
+  fields:
+    site: 1
+    name: "6c"
+    short_name: "6c"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 104
+  fields:
+    site: 1
+    name: "5a"
+    short_name: "5a"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 105
+  fields:
+    site: 1
+    name: "7a"
+    short_name: "7a"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 106
+  fields:
+    site: 1
+    name: "6a"
+    short_name: "6a"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 107
+  fields:
+    site: 1
+    name: "9d"
+    short_name: "9d"
+    members: [ 6, 7, 8 ]
+- model: core.group
+  pk: 1
+  fields:
+    site: 1
+    name: "8c:Mu"
+    short_name: "8c:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 2 ]
+    parent_groups: [ 101 ]
+- model: core.group
+  pk: 2
+  fields:
+    site: 1
+    name: "5c:Mu"
+    short_name: "5c:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 2 ]
+    parent_groups: [ 102 ]
+- model: core.group
+  pk: 3
+  fields:
+    site: 1
+    name: "6c:Mu"
+    short_name: "6c:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 3 ]
+    parent_groups: [ 103 ]
+- model: core.group
+  pk: 4
+  fields:
+    site: 1
+    name: "5a:De"
+    short_name: "5a:De"
+
+    members: [ 6, 7, 8 ]
+    owners: [ 4 ]
+    parent_groups: [ 104 ]
+- model: core.group
+  pk: 5
+  fields:
+    site: 1
+    name: "7a:Mu"
+    short_name: "7a:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 4 ]
+    parent_groups: [ 105 ]
+- model: core.group
+  pk: 6
+  fields:
+    site: 1
+    name: "6a:Mu"
+    short_name: "6a:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 5 ]
+    parent_groups: [ 106 ]
+- model: core.group
+  pk: 7
+  fields:
+    site: 1
+    name: "9d:Mu"
+    short_name: "9d:Mu"
+    members: [ 6, 7, 8 ]
+    owners: [ 5 ]
+    parent_groups: [ 107 ]
+- model: core.group
+  pk: 8
+  fields:
+    site: 1
+    name: "6a:De"
+    short_name: "6a:De"
+    members: [ 6, 7, 8 ]
+    owners: [ 5 ]
+    parent_groups: [ 106 ]
+- model: core.group
+  pk: 9
+  fields:
+    site: 1
+    name: "Teachers"
+    short_name: "Teachers"
+    members: [ 2, 3, 4, 5 ]
+- model: core.group
+  pk: 10
+  fields:
+    site: 1
+    name: "Students"
+    short_name: "Students"
+    members: [ 6, 7, 8 ]
diff --git a/aleksis/apps/matrix/filters.py b/aleksis/apps/matrix/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac7c9184431babd64be2c969e8737a35ca2df0c9
--- /dev/null
+++ b/aleksis/apps/matrix/filters.py
@@ -0,0 +1,5 @@
+from aleksis.core.filters import GroupFilter
+
+
+class GroupMatrixRoomFilter(GroupFilter):
+    pass
diff --git a/aleksis/apps/matrix/forms.py b/aleksis/apps/matrix/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..e194a7433b3c1674dcb72e140b0708c953f4ba21
--- /dev/null
+++ b/aleksis/apps/matrix/forms.py
@@ -0,0 +1,17 @@
+from django.utils.translation import gettext as _
+
+from aleksis.apps.matrix.tasks import use_groups_in_matrix
+from aleksis.core.forms import ActionForm
+
+
+def use_in_matrix_action(modeladmin, request, queryset):
+    """Use selected groups in Matrix."""
+    use_groups_in_matrix.delay(list(queryset.values_list("pk", flat=True)))
+
+
+use_in_matrix_action.short_description = _("Use in Matrix")
+
+
+class GroupMatrixRoomActionForm(ActionForm):
+    def get_actions(self):
+        return [use_in_matrix_action]
diff --git a/aleksis/apps/matrix/matrix.py b/aleksis/apps/matrix/matrix.py
index 5184286315a91ff4e79fe34be442b65831e78c13..41a7b04cc13b2b1b47063f2b897be7689b4d564a 100644
--- a/aleksis/apps/matrix/matrix.py
+++ b/aleksis/apps/matrix/matrix.py
@@ -1,5 +1,10 @@
+import time
+from json import JSONDecodeError
+from typing import Any, Dict, Optional
 from urllib.parse import urljoin
 
+import requests
+
 from aleksis.core.util.core_helpers import get_site_preferences
 
 
@@ -17,3 +22,24 @@ def get_headers():
     return {
         "Authorization": "Bearer " + get_site_preferences()["matrix__access_token"],
     }
+
+
+def do_matrix_request(method: str, url: str, body: Optional[dict] = None) -> Dict[str, Any]:
+    """Do a HTTP request to the Matrix Client Server API."""
+    while True:
+        res = requests.request(method=method, url=build_url(url), headers=get_headers(), json=body)
+        if res.status_code != requests.codes.ok:
+            try:
+                data = res.json()
+            except JSONDecodeError:
+                raise MatrixException(res.text)
+
+            # If rate limit exceeded, wait and retry
+            if data.get("errcode", "") == "M_LIMIT_EXCEEDED":
+                time.sleep(data["retry_after_ms"] / 1000)
+            else:
+                raise MatrixException(data)
+        else:
+            break
+
+    return res.json()
diff --git a/aleksis/apps/matrix/menus.py b/aleksis/apps/matrix/menus.py
new file mode 100644
index 0000000000000000000000000000000000000000..9eac098aa10389647c89f5c5d0f369522e7d3937
--- /dev/null
+++ b/aleksis/apps/matrix/menus.py
@@ -0,0 +1,31 @@
+from django.utils.translation import gettext_lazy as _
+
+MENUS = {
+    "NAV_MENU_CORE": [
+        {
+            "name": _("Matrix"),
+            "url": "#",
+            "icon": "chat",
+            "root": True,
+            "validators": [
+                (
+                    "aleksis.core.util.predicates.permission_validator",
+                    "matrix.show_menu_rule",
+                ),
+            ],
+            "submenu": [
+                {
+                    "name": _("Groups and Rooms"),
+                    "url": "matrix_rooms",
+                    "icon": "group_work",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "matrix.view_matrixrooms_rule",
+                        ),
+                    ],
+                },
+            ],
+        }
+    ]
+}
diff --git a/aleksis/apps/matrix/migrations/0001_initial.py b/aleksis/apps/matrix/migrations/0001_initial.py
index 60d62d51ba52a4b9b668301b851c911e62adf170..c5aad5550dfa1e9f6c4131e442c94da9ba57c5f6 100644
--- a/aleksis/apps/matrix/migrations/0001_initial.py
+++ b/aleksis/apps/matrix/migrations/0001_initial.py
@@ -24,13 +24,14 @@ class Migration(migrations.Migration):
                 ('extended_data', models.JSONField(default=dict, editable=False)),
                 ('room_id', models.CharField(max_length=255, unique=True, verbose_name='Room ID')),
                 ('alias', models.CharField(blank=True, max_length=255, unique=True, verbose_name='Alias')),
-                ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='matrix_spaces', to='core.group', verbose_name='Group')),
+                ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='matrix_room', to='core.group', verbose_name='Group')),
                 ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_matrix.matrixroom_set+', to='contenttypes.contenttype')),
                 ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
             ],
             options={
                 'verbose_name': 'Matrix room',
                 'verbose_name_plural': 'Matrix rooms',
+                'permissions': (('use_group_in_matrix', 'Can use group in Matrix'),),
             },
             managers=[
                 ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()),
diff --git a/aleksis/apps/matrix/model_extensions.py b/aleksis/apps/matrix/model_extensions.py
new file mode 100644
index 0000000000000000000000000000000000000000..2fca9b202dfddae2d01dd7fd589ec3537ea1442c
--- /dev/null
+++ b/aleksis/apps/matrix/model_extensions.py
@@ -0,0 +1,45 @@
+from typing import Optional, Union
+
+from django.utils.translation import gettext_lazy as _
+
+from celery.result import AsyncResult
+
+from aleksis.apps.matrix.models import MatrixRoom
+from aleksis.apps.matrix.tasks import use_group_in_matrix
+from aleksis.core.models import Group
+
+
+@Group.method
+def use_in_matrix(self, sync=False) -> Union[MatrixRoom, AsyncResult]:
+    """Create and sync a room for this group in Matrix."""
+    if sync:
+        return self._use_in_matrix()
+    else:
+        return use_group_in_matrix.delay(self.pk)
+
+
+@Group.method
+def _use_in_matrix(self):
+    """Create and sync a room for this group in Matrix."""
+    room = MatrixRoom.from_group(self)
+    room.sync()
+    return room
+
+
+@Group.property_
+def matrix_alias(self) -> Optional[str]:
+    """Return the alias of the group's room in Matrix."""
+    if hasattr(self, "matrix_room"):
+        return self.matrix_room.alias
+    return None
+
+
+@Group.property_
+def matrix_room_id(self) -> Optional[str]:
+    """Return the ID of the group's room in Matrix."""
+    if hasattr(self, "matrix_room"):
+        return self.matrix_room.room_id
+    return None
+
+
+Group.add_permission("view_matrixroom", _("Can view matrix room of a group"))
diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py
index 78c9fd99c609c86961688ce879fd33fc99d34f4d..fff467f7a0ab722c3cd9536cad8ef1da5945e827 100644
--- a/aleksis/apps/matrix/models.py
+++ b/aleksis/apps/matrix/models.py
@@ -1,13 +1,12 @@
 import re
-from typing import Any, Dict, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 
 from django.db import models
 from django.db.models import Q
 from django.template.defaultfilters import slugify
 from django.utils.translation import gettext_lazy as _
 
-import requests
-
+from aleksis.apps.matrix.matrix import do_matrix_request
 from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel
 from aleksis.core.models import Group, Person
 from aleksis.core.util.core_helpers import get_site_preferences
@@ -56,19 +55,17 @@ class MatrixRoom(ExtensiblePolymorphicModel):
 
     room_id = models.CharField(max_length=255, verbose_name=_("Room ID"), unique=True)
     alias = models.CharField(max_length=255, verbose_name=_("Alias"), unique=True, blank=True)
-    group = models.ForeignKey(
+    group = models.OneToOneField(
         Group,
         on_delete=models.CASCADE,
         verbose_name=_("Group"),
-        null=True,
-        blank=True,
-        related_name="matrix_spaces",
+        related_name="matrix_room",
     )
 
     @classmethod
     def from_group(self, group: Group):
         """Create a Matrix room from a group."""
-        from .matrix import MatrixException, build_url, get_headers
+        from .matrix import MatrixException, do_matrix_request
 
         try:
             room = MatrixRoom.objects.get(group=group)
@@ -77,16 +74,27 @@ class MatrixRoom(ExtensiblePolymorphicModel):
 
         if room.room_id:
             # Existing room, check if still accessible
-            r = requests.get(
-                build_url(f"directory/list/room/{room.room_id}"), headers=get_headers()
-            )
-            if not r.status_code == requests.codes.ok:
-                raise MatrixException()
+            r = do_matrix_request("GET", f"directory/list/room/{room.room_id}")
         else:
             # Room does not exist, create it
             alias = slugify(group.short_name or group.name)
-            r = self._create_group(group.name, alias)
-            while r.json().get("errcode") == "M_ROOM_IN_USE":
+            profiles_to_invite = list(
+                self.get_profiles_for_group(group).values_list("matrix_id", flat=True)
+            )
+
+            alias_found = False
+            while not alias_found:
+                try:
+                    r = self._create_room(group.name, alias, profiles_to_invite)
+                    alias_found = True
+                except MatrixException as e:
+                    print(e.args, get_site_preferences()["matrix__disambiguate_room_aliases"])
+                    if (
+                        not get_site_preferences()["matrix__disambiguate_room_aliases"]
+                        or e.args[0].get("errcode") != "M_ROOM_IN_USE"
+                    ):
+                        raise MatrixException(*e.args)
+
                 match = re.match(r"^(.*)-(\d+)$", alias)
                 if match:
                     # Counter found, increase
@@ -96,75 +104,72 @@ class MatrixRoom(ExtensiblePolymorphicModel):
                 else:
                     # Counter not found, add one
                     alias = f"{alias}-2"
-                r = self._create_group(group.name, alias)
-
-            if r.status_code == requests.codes.ok:
-                room.room_id = r.json()["room_id"]
-                room.alias = r.json()["room_alias"]
-                room.save()
-            else:
-                raise MatrixException(r.text)
+
+            room.room_id = r["room_id"]
+            room.alias = r["room_alias"]
+            room.save()
         return room
 
     @classmethod
-    def _create_group(self, name, alias):
-        from .matrix import build_url, get_headers
+    def _create_room(self, name, alias, invite: List[str]):
+        from .matrix import do_matrix_request
 
-        body = {"preset": "private_chat", "name": name, "room_alias_name": alias}
-        r = requests.post(build_url("createRoom"), headers=get_headers(), json=body)
+        body = {"preset": "private_chat", "name": name, "room_alias_name": alias, "invite": invite}
+        r = do_matrix_request("POST", "createRoom", body=body)
 
         return r
 
     @property
     def power_levels(self) -> Dict[str, int]:
         """Return the power levels for this room."""
-        from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers
+        from aleksis.apps.matrix.matrix import do_matrix_request
 
-        r = requests.get(build_url(f"rooms/{self.room_id}/state"), headers=get_headers())
-        if r.status_code != requests.codes.ok:
-            raise MatrixException(r.text)
+        r = do_matrix_request("GET", f"rooms/{self.room_id}/state")
 
-        event = list(filter(lambda x: x["type"] == "m.room.power_levels", r.json()))
+        event = list(filter(lambda x: x["type"] == "m.room.power_levels", r))
         user_levels = event[0]["content"]["users"]
 
         return user_levels
 
+    @property
+    def members(self) -> List[str]:
+        from aleksis.apps.matrix.matrix import do_matrix_request
+
+        r = do_matrix_request(
+            "GET", f"rooms/{self.room_id}/members", body={"membership": ["join", "invite"]}
+        )
+        return [m["state_key"] for m in r["chunk"]]
+
     def _invite(self, profile: MatrixProfile) -> Dict[str, Any]:
         """Invite a user to this room."""
-        from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers
+        from aleksis.apps.matrix.matrix import do_matrix_request
 
-        r = requests.post(
-            build_url(f"rooms/{self.room_id}/invite"),
-            headers=get_headers(),
-            json={"user_id": profile.matrix_id},
+        r = do_matrix_request(
+            "POST",
+            f"rooms/{self.room_id}/invite",
+            body={"user_id": profile.matrix_id},
         )
-        if not r.status_code == requests.codes.ok:
-            raise MatrixException(r.text)
-        return r.json()
+        return r
 
     def _set_power_levels(self, power_levels: Dict[str, int]) -> Dict[str, Any]:
         """Set the power levels for this room."""
-        from aleksis.apps.matrix.matrix import MatrixException, build_url, get_headers
-
-        r = requests.put(
-            build_url(f"rooms/{self.room_id}/state/m.room.power_levels/"),
-            headers=get_headers(),
-            json={"users": power_levels},
+        r = do_matrix_request(
+            "PUT",
+            f"rooms/{self.room_id}/state/m.room.power_levels/",
+            body={"users": power_levels},
         )
-        print(r.text, r.status_code)
-        if not r.status_code == requests.codes.ok:
-            raise MatrixException(r.text)
-        return r.json()
+        return r
 
-    def sync_profiles(self):
-        """Sync profiles for this room."""
+    @classmethod
+    def get_profiles_for_group(cls, group: Group):
+        """Get all profile objects for the members/owners of a group."""
         existing_profiles = MatrixProfile.objects.filter(
-            Q(person__member_of=self.group) | Q(person__owner_of=self.group)
+            Q(person__member_of=group) | Q(person__owner_of=group)
         )
         profiles_to_create = []
         for person in (
             Person.objects.filter(user__isnull=False)
-            .filter(Q(member_of=self.group) | Q(owner_of=self.group))
+            .filter(Q(member_of=group) | Q(owner_of=group))
             .exclude(matrix_profile__in=existing_profiles)
             .distinct()
         ):
@@ -174,13 +179,23 @@ class MatrixRoom(ExtensiblePolymorphicModel):
         MatrixProfile.objects.bulk_create(profiles_to_create)
 
         all_profiles = MatrixProfile.objects.filter(
-            Q(person__in=self.group.members.all()) | Q(person__in=self.group.owners.all())
+            Q(person__in=group.members.all()) | Q(person__in=group.owners.all())
         )
-        user_levels = self.power_levels
+
+        return all_profiles
+
+    def get_profiles(self):
+        """Get all profile objects for the members/owners of this group."""
+        return self.get_profiles_for_group(self.group)
+
+    def sync_profiles(self):
+        """Sync profiles for this room."""
+        all_profiles = self.get_profiles()
+        members = self.members
 
         # Invite all users who are not in the room yet
         for profile in all_profiles:
-            if profile.matrix_id not in user_levels:
+            if profile.matrix_id not in members:
                 # Now invite
                 self._invite(profile)
 
@@ -195,9 +210,14 @@ class MatrixRoom(ExtensiblePolymorphicModel):
                 user_levels[profile.matrix_id] = 0
         self._set_power_levels(user_levels)
 
+    def sync(self):
+        """Sync this room."""
+        self.sync_profiles()
+
     class Meta:
         verbose_name = _("Matrix room")
         verbose_name_plural = _("Matrix rooms")
+        permissions = (("use_group_in_matrix", "Can use group in Matrix"),)
 
 
 class MatrixSpace(MatrixRoom):
diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py
index 124c42f7ecd97d6e919d4d0b6e213726c49819da..72967e2e5552e04962ede652277b79e8d72d8c89 100644
--- a/aleksis/apps/matrix/preferences.py
+++ b/aleksis/apps/matrix/preferences.py
@@ -47,7 +47,6 @@ class DeviceID(StringPreference):
     name = "device_id"
     verbose_name = _("Device ID")
     default = ""
-    field_kwargs = {"editable": False}
 
 
 @site_preferences_registry.register
diff --git a/aleksis/apps/matrix/rules.py b/aleksis/apps/matrix/rules.py
new file mode 100644
index 0000000000000000000000000000000000000000..4628e35ad5a32880211163731d14459960336c81
--- /dev/null
+++ b/aleksis/apps/matrix/rules.py
@@ -0,0 +1,24 @@
+# View groups
+import rules
+
+from aleksis.core.models import Group
+from aleksis.core.rules import view_group_predicate, view_groups_predicate
+from aleksis.core.util.predicates import has_any_object, has_global_perm, has_object_perm
+
+view_matrix_rooms_predicate = view_groups_predicate & (
+    has_global_perm("matrix.view_matrixroom") | has_any_object("core.view_matrixroom", Group)
+)
+rules.add_perm("matrix.view_matrixrooms_rule", view_matrix_rooms_predicate)
+
+view_matrix_room_predicate = view_group_predicate & (
+    has_global_perm("matrix.view_matrixroom") | has_object_perm("core.view_matrixroom")
+)
+rules.add_perm("matrix.view_matrixroom_rule", view_matrix_room_predicate)
+
+use_room_for_matrix_predicate = view_matrix_room_predicate & (
+    has_global_perm("matrix.use_group_in_matrix")
+)
+rules.add_perm("matrix.use_group_in_matrix_rule", use_room_for_matrix_predicate)
+
+show_menu_predicate = view_matrix_rooms_predicate
+rules.add_perm("matrix.show_menu_rule", show_menu_predicate)
diff --git a/aleksis/apps/matrix/tables.py b/aleksis/apps/matrix/tables.py
new file mode 100644
index 0000000000000000000000000000000000000000..b8bb54b4a13c987581c93e3ffa42dc01fca1e03b
--- /dev/null
+++ b/aleksis/apps/matrix/tables.py
@@ -0,0 +1,16 @@
+from django_tables2 import Column, Table
+
+from aleksis.core.util.tables import SelectColumn
+
+
+class GroupsMatrixRoomsTable(Table):
+    """Table to list groups together with their Matrix rooms."""
+
+    class Meta:
+        attrs = {"class": "highlight"}
+
+    selected = SelectColumn()
+    name = Column()
+    short_name = Column()
+    matrix_alias = Column()
+    matrix_room_id = Column()
diff --git a/aleksis/apps/matrix/tasks.py b/aleksis/apps/matrix/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..20f8d5750efd2bb1fdba97b32afd0ec26345b4f6
--- /dev/null
+++ b/aleksis/apps/matrix/tasks.py
@@ -0,0 +1,24 @@
+from typing import Sequence
+
+from aleksis.apps.matrix.models import MatrixRoom
+from aleksis.core.celery import app
+from aleksis.core.models import Group
+
+
+@app.task
+def sync_room(pk: int):
+    room = MatrixRoom.objects.get(pk=pk)
+    room.sync()
+
+
+@app.task
+def use_groups_in_matrix(pks: Sequence[int]):
+    groups = Group.objects.filter(pk__in=pks)
+    for group in groups:
+        group._use_in_matrix()
+
+
+@app.task
+def use_group_in_matrix(pk: int):
+    group = Group.objects.get(pk=pk)
+    group._use_in_matrix()
diff --git a/aleksis/apps/matrix/templates/matrix/room/list.html b/aleksis/apps/matrix/templates/matrix/room/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..b1b326c876bc14e771ffd11f778b1c94688e638c
--- /dev/null
+++ b/aleksis/apps/matrix/templates/matrix/room/list.html
@@ -0,0 +1,61 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n material_form static %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Groups and Matrix Rooms{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Groups and Matrix Rooms{% endblocktrans %}{% endblock %}
+
+{% block content %}
+<!--  <a class="btn green waves-effect waves-light" href="{% url 'create_group' %}">-->
+<!--    <i class="material-icons left">add</i>-->
+<!--    {% trans "Create group" %}-->
+<!--  </a>-->
+
+<div class="card">
+  <div class="card-content">
+    <div class="card-title">{% trans "Filter groups" %}</div>
+    <form method="get">
+      {% form form=filter.form %}{% endform %}
+      {% trans "Search" as caption %}
+      {% include "core/partials/save_button.html" with caption=caption icon="search" %}
+      <button type="reset" class="btn red waves-effect waves-light">
+        <i class="material-icons left">clear</i>
+        {% trans "Clear" %}
+      </button>
+    </form>
+  </div>
+</div>
+
+
+<div class="card">
+  <div class="card-content">
+    <form action="" method="post">
+      {% csrf_token %}
+      <div class="row">
+        <div class="col s12 {% if action_form %}m4 l4 xl6{% endif %}">
+          <div class="card-title">{% trans "Selected groups" %}</div>
+        </div>
+        {% if action_form %}
+        <div class="col s12 m8 l8 xl6">
+          <div class="col s12 m8">
+            {% form form=action_form %}{% endform %}
+          </div>
+          <div class="col s12 m4">
+            <button type="submit" class="btn waves-effect waves-primary">
+              {% trans "Execute" %}
+              <i class="material-icons right">send</i>
+            </button>
+          </div>
+        </div>
+        {% endif %}
+      </div>
+      {% render_table table %}
+
+    </form>
+  </div>
+</div>
+<script src="{% static "js/multi_select.js" %}"></script>
+{% endblock %}
diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py
index 00a8efdf75c084c0a1ec993a43335917bd9d1056..f2a049e57fd957952a58ba00e84996bc35dd1e4c 100644
--- a/aleksis/apps/matrix/tests/test_matrix.py
+++ b/aleksis/apps/matrix/tests/test_matrix.py
@@ -1,10 +1,13 @@
+import time
 from datetime import date
 
 from django.contrib.auth.models import User
 
 import pytest
 import requests
+from celery.result import AsyncResult
 
+from aleksis.apps.matrix.matrix import do_matrix_request
 from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom
 from aleksis.core.models import Group, Person, SchoolTerm
 from aleksis.core.util.core_helpers import get_site_preferences
@@ -40,7 +43,12 @@ def matrix_bot_user(synapse):
     get_site_preferences()["matrix__device_id"] = user["device_id"]
     get_site_preferences()["matrix__access_token"] = user["access_token"]
 
-    yield r.json()
+    yield user
+
+
+def test_matrix_bot_user(matrix_bot_user):
+    print(matrix_bot_user)
+    assert True
 
 
 def test_create_room_for_group(matrix_bot_user):
@@ -56,8 +64,8 @@ def test_create_room_for_group(matrix_bot_user):
     # On second get, it should be the same matrix room
     assert MatrixRoom.from_group(g) == room
 
-    r = requests.get(build_url(f"rooms/{room.room_id}/aliases"), headers=get_headers())
-    aliases = r.json()["aliases"]
+    r = do_matrix_request("GET", f"rooms/{room.room_id}/aliases")
+    aliases = r["aliases"]
     assert "#test-room:matrix.aleksis.example.org" in aliases
 
 
@@ -77,6 +85,9 @@ def test_room_alias_collision_same_name(matrix_bot_user):
     g2 = Group.objects.create(name="test-room")
     g3 = Group.objects.create(name="Test-Room")
     g4 = Group.objects.create(name="test room")
+
+    get_site_preferences()["matrix__disambiguate_room_aliases"] = True
+
     room = MatrixRoom.from_group(g1)
     assert room.alias == "#test-room:matrix.aleksis.example.org"
 
@@ -93,6 +104,8 @@ def test_room_alias_collision_same_name(matrix_bot_user):
 
 
 def test_room_alias_collision_school_term(matrix_bot_user):
+    get_site_preferences()["matrix__disambiguate_room_aliases"] = True
+
     school_term_a = SchoolTerm.objects.create(
         name="Test Term A", date_start=date(2020, 1, 1), date_end=date(2020, 12, 31)
     )
@@ -146,14 +159,13 @@ def test_sync_room_members(matrix_bot_user):
     assert p5.matrix_profile.matrix_id == "@test5:matrix.aleksis.example.org"
 
     # Check members
-    r = requests.get(
-        build_url(f"rooms/{room.room_id}/members"),
-        headers=get_headers(),
-        json={"membership": ["join", "invite"]},
+    r = do_matrix_request(
+        "GET",
+        f"rooms/{room.room_id}/members",
+        body={"membership": ["join", "invite"]},
     )
-    assert r.status_code == requests.codes.ok
 
-    matrix_ids = [x["state_key"] for x in r.json()["chunk"]]
+    matrix_ids = [x["state_key"] for x in r["chunk"]]
     assert p1.matrix_profile.matrix_id in matrix_ids
     assert p2.matrix_profile.matrix_id in matrix_ids
     assert p3.matrix_profile.matrix_id in matrix_ids
@@ -161,9 +173,8 @@ def test_sync_room_members(matrix_bot_user):
     assert p5.matrix_profile.matrix_id in matrix_ids
 
     # Get power levels
-    r = requests.get(build_url(f"rooms/{room.room_id}/state"), headers=get_headers())
-    assert r.status_code == requests.codes.ok
-    for event in r.json():
+    r = do_matrix_request("GET", f"rooms/{room.room_id}/state")
+    for event in r:
         if not event["type"] == "m.room.power_levels":
             continue
         current_power_levels = event["content"]["users"]
@@ -221,3 +232,53 @@ def test_sync_room_members_without_homeserver(matrix_bot_user):
     assert not hasattr(p1, "matrix_profile")
     assert p2.matrix_profile
     assert p2.matrix_profile.matrix_id == "@test2:matrix.aleksis.example.org"
+
+
+def test_use_room_sync(matrix_bot_user):
+    from aleksis.apps.matrix.matrix import build_url, get_headers
+
+    get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org"
+
+    g = Group.objects.create(name="Test Room")
+    u1 = User.objects.create_user("test1", "test1@example.org", "test1")
+
+    p1 = Person.objects.create(first_name="Test", last_name="Person", user=u1)
+
+    g.members.add(p1)
+
+    r = g.use_in_matrix(sync=True)
+
+    assert isinstance(r, MatrixRoom)
+
+    assert MatrixProfile.objects.all().count() == 1
+    assert p1.matrix_profile
+    assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org"
+
+
+from django.test import TransactionTestCase, override_settings
+
+
+@pytest.mark.usefixtures("celery_worker", "matrix_bot_user")
+@override_settings(CELERY_BROKER_URL="memory://localhost//")
+@override_settings(HAYSTACK_SIGNAL_PROCESSOR="")
+class MatrixCeleryTest(TransactionTestCase):
+    serialized_rollback = True
+
+    def test_use_room_async(self):
+        get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org"
+
+        g = Group.objects.create(name="Test Room")
+        u1 = User.objects.create_user("test1", "test1@example.org", "test1")
+
+        p1 = Person.objects.create(first_name="Test", last_name="Person", user=u1)
+
+        g.members.add(p1)
+
+        r = g.use_in_matrix(sync=False)
+        assert isinstance(r, AsyncResult)
+
+        time.sleep(3)
+
+        assert MatrixProfile.objects.all().count() == 1
+        assert p1.matrix_profile
+        assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org"
diff --git a/aleksis/apps/matrix/urls.py b/aleksis/apps/matrix/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3619969d993c03eec24aaa94f3ed71d2ec4f938
--- /dev/null
+++ b/aleksis/apps/matrix/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+    path("rooms/", views.MatrixRoomListView.as_view(), name="matrix_rooms"),
+]
diff --git a/aleksis/apps/matrix/views.py b/aleksis/apps/matrix/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..92c4aafa2655773646a0e2ae0634e0112edc70fb
--- /dev/null
+++ b/aleksis/apps/matrix/views.py
@@ -0,0 +1,37 @@
+from django_filters.views import FilterView
+from django_tables2 import SingleTableMixin
+from guardian.shortcuts import get_objects_for_user
+from rules.contrib.views import PermissionRequiredMixin
+
+from aleksis.apps.matrix.filters import GroupMatrixRoomFilter
+from aleksis.apps.matrix.forms import GroupMatrixRoomActionForm
+from aleksis.apps.matrix.tables import GroupsMatrixRoomsTable
+from aleksis.core.models import Group
+
+
+class MatrixRoomListView(PermissionRequiredMixin, SingleTableMixin, FilterView):
+    model = Group
+    template_name = "matrix/room/list.html"
+    permission_required = "matrix.view_matrixrooms_rule"
+    table_class = GroupsMatrixRoomsTable
+    filterset_class = GroupMatrixRoomFilter
+
+    def get_queryset(self):
+        return get_objects_for_user(
+            self.request.user, ["core.view_group", "core.view_matrixroom"], Group
+        )
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        self.action_form = GroupMatrixRoomActionForm(
+            self.request, self.request.POST or None, queryset=self.get_queryset()
+        )
+        context["action_form"] = self.action_form
+        return context
+
+    def post(self, request, *args, **kwargs):
+        r = super().get(request, *args, **kwargs)
+        if self.action_form.is_valid() and request.user.has_perm("matrix.use_group_in_matrix_rule"):
+            self.action_form.execute()
+        return r
diff --git a/conftest.py b/conftest.py
index eadfed813a9ec1b1bfe9b5e4b21374d70cd8992b..bdd05deeafd0174838d11958e7a028cdb73d80eb 100644
--- a/conftest.py
+++ b/conftest.py
@@ -4,6 +4,8 @@ import pytest
 import yaml
 from xprocess import ProcessStarter
 
+pytest_plugins = ("celery.contrib.pytest",)
+
 
 @pytest.fixture
 def synapse(xprocess, tmp_path):