diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..996cd5aad4d7331e9c74dcaa7eb6483a5116eed9
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,216 @@
+module.exports = {
+  extends: [
+    "eslint:recommended",
+    "plugin:vue/strongly-recommended",
+    //    "plugin:prettier/recommended",
+    "plugin:@intlify/vue-i18n/recommended",
+  ],
+  rules: {
+    "no-unused-vars": "warn",
+    "vue/no-unused-vars": "off",
+    "vue/multi-word-component-names": "off",
+    "@intlify/vue-i18n/key-format-style": [
+      "error",
+      "snake_case",
+      {
+        splitByDots: false,
+      },
+    ],
+    // "@intlify/vue-i18n/no-unused-keys": ["warn", {}],
+    "@intlify/vue-i18n/no-raw-text": [
+      "error",
+      {
+        ignoreNodes: ["v-icon"],
+        ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$",
+      },
+    ],
+    // Fixes for prettier (avoid eslint-config-prettier)
+    // The following rules can be used in some cases. See the README for more
+    // information. (These are marked with `0` instead of `"off"` so that a
+    // script can distinguish them.)
+    curly: 0,
+    "lines-around-comment": 0,
+    "max-len": 0,
+    "no-confusing-arrow": 0,
+    "no-mixed-operators": 0,
+    "no-tabs": 0,
+    "no-unexpected-multiline": 0,
+    quotes: 0,
+    "@typescript-eslint/quotes": 0,
+    "babel/quotes": 0,
+    "vue/html-self-closing": 0,
+    "vue/max-len": 0,
+
+    // The rest are rules that you never need to enable when using Prettier.
+    "array-bracket-newline": "off",
+    "array-bracket-spacing": "off",
+    "array-element-newline": "off",
+    "arrow-parens": "off",
+    "arrow-spacing": "off",
+    "block-spacing": "off",
+    "brace-style": "off",
+    "comma-dangle": "off",
+    "comma-spacing": "off",
+    "comma-style": "off",
+    "computed-property-spacing": "off",
+    "dot-location": "off",
+    "eol-last": "off",
+    "func-call-spacing": "off",
+    "function-call-argument-newline": "off",
+    "function-paren-newline": "off",
+    "generator-star": "off",
+    "generator-star-spacing": "off",
+    "implicit-arrow-linebreak": "off",
+    indent: "off",
+    "jsx-quotes": "off",
+    "key-spacing": "off",
+    "keyword-spacing": "off",
+    "linebreak-style": "off",
+    "multiline-ternary": "off",
+    "newline-per-chained-call": "off",
+    "new-parens": "off",
+    "no-arrow-condition": "off",
+    "no-comma-dangle": "off",
+    "no-extra-parens": "off",
+    "no-extra-semi": "off",
+    "no-floating-decimal": "off",
+    "no-mixed-spaces-and-tabs": "off",
+    "no-multi-spaces": "off",
+    "no-multiple-empty-lines": "off",
+    "no-reserved-keys": "off",
+    "no-space-before-semi": "off",
+    "no-trailing-spaces": "off",
+    "no-whitespace-before-property": "off",
+    "no-wrap-func": "off",
+    "nonblock-statement-body-position": "off",
+    "object-curly-newline": "off",
+    "object-curly-spacing": "off",
+    "object-property-newline": "off",
+    "one-var-declaration-per-line": "off",
+    "operator-linebreak": "off",
+    "padded-blocks": "off",
+    "quote-props": "off",
+    "rest-spread-spacing": "off",
+    semi: "off",
+    "semi-spacing": "off",
+    "semi-style": "off",
+    "space-after-function-name": "off",
+    "space-after-keywords": "off",
+    "space-before-blocks": "off",
+    "space-before-function-paren": "off",
+    "space-before-function-parentheses": "off",
+    "space-before-keywords": "off",
+    "space-in-brackets": "off",
+    "space-in-parens": "off",
+    "space-infix-ops": "off",
+    "space-return-throw-case": "off",
+    "space-unary-ops": "off",
+    "space-unary-word-ops": "off",
+    "switch-colon-spacing": "off",
+    "template-curly-spacing": "off",
+    "template-tag-spacing": "off",
+    "unicode-bom": "off",
+    "wrap-iife": "off",
+    "wrap-regex": "off",
+    "yield-star-spacing": "off",
+    "@babel/object-curly-spacing": "off",
+    "@babel/semi": "off",
+    "@typescript-eslint/brace-style": "off",
+    "@typescript-eslint/comma-dangle": "off",
+    "@typescript-eslint/comma-spacing": "off",
+    "@typescript-eslint/func-call-spacing": "off",
+    "@typescript-eslint/indent": "off",
+    "@typescript-eslint/keyword-spacing": "off",
+    "@typescript-eslint/member-delimiter-style": "off",
+    "@typescript-eslint/no-extra-parens": "off",
+    "@typescript-eslint/no-extra-semi": "off",
+    "@typescript-eslint/object-curly-spacing": "off",
+    "@typescript-eslint/semi": "off",
+    "@typescript-eslint/space-before-blocks": "off",
+    "@typescript-eslint/space-before-function-paren": "off",
+    "@typescript-eslint/space-infix-ops": "off",
+    "@typescript-eslint/type-annotation-spacing": "off",
+    "babel/object-curly-spacing": "off",
+    "babel/semi": "off",
+    "flowtype/boolean-style": "off",
+    "flowtype/delimiter-dangle": "off",
+    "flowtype/generic-spacing": "off",
+    "flowtype/object-type-curly-spacing": "off",
+    "flowtype/object-type-delimiter": "off",
+    "flowtype/quotes": "off",
+    "flowtype/semi": "off",
+    "flowtype/space-after-type-colon": "off",
+    "flowtype/space-before-generic-bracket": "off",
+    "flowtype/space-before-type-colon": "off",
+    "flowtype/union-intersection-spacing": "off",
+    "react/jsx-child-element-spacing": "off",
+    "react/jsx-closing-bracket-location": "off",
+    "react/jsx-closing-tag-location": "off",
+    "react/jsx-curly-newline": "off",
+    "react/jsx-curly-spacing": "off",
+    "react/jsx-equals-spacing": "off",
+    "react/jsx-first-prop-new-line": "off",
+    "react/jsx-indent": "off",
+    "react/jsx-indent-props": "off",
+    "react/jsx-max-props-per-line": "off",
+    "react/jsx-newline": "off",
+    "react/jsx-one-expression-per-line": "off",
+    "react/jsx-props-no-multi-spaces": "off",
+    "react/jsx-tag-spacing": "off",
+    "react/jsx-wrap-multilines": "off",
+    "standard/array-bracket-even-spacing": "off",
+    "standard/computed-property-even-spacing": "off",
+    "standard/object-curly-even-spacing": "off",
+    "unicorn/empty-brace-spaces": "off",
+    "unicorn/no-nested-ternary": "off",
+    "unicorn/number-literal-case": "off",
+    "vue/array-bracket-newline": "off",
+    "vue/array-bracket-spacing": "off",
+    "vue/arrow-spacing": "off",
+    "vue/block-spacing": "off",
+    "vue/block-tag-newline": "off",
+    "vue/brace-style": "off",
+    "vue/comma-dangle": "off",
+    "vue/comma-spacing": "off",
+    "vue/comma-style": "off",
+    "vue/dot-location": "off",
+    "vue/func-call-spacing": "off",
+    "vue/html-closing-bracket-newline": "off",
+    "vue/html-closing-bracket-spacing": "off",
+    "vue/html-end-tags": "off",
+    "vue/html-indent": "off",
+    "vue/html-quotes": "off",
+    "vue/key-spacing": "off",
+    "vue/keyword-spacing": "off",
+    "vue/max-attributes-per-line": "off",
+    "vue/multiline-html-element-content-newline": "off",
+    "vue/multiline-ternary": "off",
+    "vue/mustache-interpolation-spacing": "off",
+    "vue/no-extra-parens": "off",
+    "vue/no-multi-spaces": "off",
+    "vue/no-spaces-around-equal-signs-in-attribute": "off",
+    "vue/object-curly-newline": "off",
+    "vue/object-curly-spacing": "off",
+    "vue/object-property-newline": "off",
+    "vue/operator-linebreak": "off",
+    "vue/quote-props": "off",
+    "vue/script-indent": "off",
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/space-in-parens": "off",
+    "vue/space-infix-ops": "off",
+    "vue/space-unary-ops": "off",
+    "vue/template-curly-spacing": "off",
+  },
+  settings: {
+    "vue-i18n": {
+      localeDir: "./aleksis/core/frontend/messages/*.{json}",
+      messageSyntaxVersion: "^8.0.0",
+    },
+  },
+  env: {
+    es2021: true,
+  },
+  parserOptions: {
+    ecmaVersion: "latest",
+  },
+};
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2b53733e8e0413b54a92dc721936cb86cfb74aa0..8beab2e52d96641789ac66d770118657f6847f02 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,19 +1,19 @@
 include:
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/general.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/prepare/lock.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/lint.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/security.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/dist.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/docs.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/deploy/trigger_dist.yml"
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/docker/image.yml"
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/publish/pypi.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/general.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/prepare/lock.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/lint.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/security.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/dist.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/docs.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/deploy/trigger_dist.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/docker/image.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/publish/pypi.yml
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..38d141b743fd55678f50077c0617924475817095
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,91 @@
+# Byte-compiled / optimized / DLL files
+*$py.class
+*.py[cod]
+__pycache__/
+
+# Distribution / packaging
+*.egg
+*.egg-info/
+.Python
+.eggs/
+.installed.cfg
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+
+# Installer logs
+pip-delete-this-directory.txt
+pip-log.txt
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# pyenv
+.python-version
+
+# Environments
+.env
+.venv
+ENV/
+env/
+venv/
+
+# Editors
+*~
+DEADJOE
+\#*#
+
+# IntelliJ
+.idea
+.idea/
+
+# Database
+db.sqlite3
+
+# Sphinx
+docs/_build/
+
+# TeX
+*.aux
+
+# Generated files
+/node_modules/
+/static/
+/whoosh_index/
+poetry.lock
+
+.coverage
+.mypy_cache/
+.tox/
+htmlcov/
+maintenance_mode_state.txt
+media/
+package-lock.json
+yarn.lock
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
+
+/cache
+
+# Add HTML files to avoid problems with unsupported Django templates
+*.html
+
+# Do not check/reformat generated files
+aleksis/core/util/licenses.json
+.vite/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 32f91354f1523062629a2758bb42db9b10aae6d0..fcfc5fa528105a3bc8b6e722b3c59c0571bff2a8 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,10 +9,30 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+`2.0`_ - 2023-05-14
+-------------------
+
+Fixed
+~~~~~
+
+* The Matrix groups and rooms threw a 404 error.
+* The Matrix parent menu point was displayed even though the user had no permission to see it.
+
+`2.0b0`_ - 2023-02-23
+---------------------
+
+This version requires AlekSIS-Core 3.0. It is incompatible with any previous
+version.
+
+Removed
+~~~~~~~
+
+* Legacy menu integration for AlekSIS-Core pre-3.0
+
 Added
 ~~~~~
 
-* Add SPA support.
+* Support for SPA in AlekSIS-Core 3.0
 
 `1.0`_ - 2022-06-25
 -------------------
@@ -26,3 +46,5 @@ Added
 .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html
 
 .. _1.0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Matrix/-/tags/1.0
+.. _2.0b0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Matrix/-/tags/2.0b0
+.. _2.0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Matrix/-/tags/2.0
diff --git a/aleksis/apps/matrix/frontend/index.js b/aleksis/apps/matrix/frontend/index.js
index 0c46c2e60e8078d0912311d8f66462708488c9d5..ff493905a446972feb8ea0bc270d5e809e7cc36c 100644
--- a/aleksis/apps/matrix/frontend/index.js
+++ b/aleksis/apps/matrix/frontend/index.js
@@ -1,27 +1,27 @@
-export default
-  {
-    meta: {
-      inMenu: true,
-      titleKey: "matrix.menu_title",
-      icon: "mdi-forum-outline",
-    },
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
-    children: [
-      {
-        path: "rooms/",
-        component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
-        name: "matrix.groupsAndRooms",
-        meta: {
-          inMenu: true,
-          titleKey: "matrix.rooms.menu_title",
-          icon: "mdi-account-group-outline",
-          permission: "matrix.view_matrixrooms_rule",
-        },
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+export default {
+  meta: {
+    inMenu: true,
+    titleKey: "matrix.menu_title",
+    icon: "mdi-forum-outline",
+    permission: "matrix.view_matrixrooms_rule",
+  },
+  props: {
+    byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+  },
+  children: [
+    {
+      path: "rooms/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "matrix.groupsAndRooms",
+      meta: {
+        inMenu: true,
+        titleKey: "matrix.rooms.menu_title",
+        icon: "mdi-account-group-outline",
+        permission: "matrix.view_matrixrooms_rule",
       },
-    ],
-  }
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
+  ],
+};
diff --git a/aleksis/apps/matrix/menus.py b/aleksis/apps/matrix/menus.py
deleted file mode 100644
index 45811de6e438b10d592973a5ff6d3db471ee64a1..0000000000000000000000000000000000000000
--- a/aleksis/apps/matrix/menus.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from django.utils.translation import gettext_lazy as _
-
-MENUS = {
-    "NAV_MENU_CORE": [
-        {
-            "name": _("Matrix"),
-            "url": "#",
-            "svg_icon": "simple-icons:matrix",
-            "root": True,
-            "validators": [
-                (
-                    "aleksis.core.util.predicates.permission_validator",
-                    "matrix.show_menu_rule",
-                ),
-            ],
-            "submenu": [
-                {
-                    "name": _("Groups and Rooms"),
-                    "url": "matrix_rooms",
-                    "svg_icon": "mdi:account-group-outline",
-                    "validators": [
-                        (
-                            "aleksis.core.util.predicates.permission_validator",
-                            "matrix.view_matrixrooms_rule",
-                        ),
-                    ],
-                },
-            ],
-        }
-    ]
-}
diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py
index 7f2a7a331fda8d849817e69db19a3f9ec566cb3f..e991dad018a0590173994c321308d5cee2c097e0 100644
--- a/aleksis/apps/matrix/models.py
+++ b/aleksis/apps/matrix/models.py
@@ -109,7 +109,6 @@ class MatrixRoom(ExtensiblePolymorphicModel):
                         not get_site_preferences()["matrix__disambiguate_room_aliases"]
                         or e.args[0].get("errcode") != "M_ROOM_IN_USE"
                     ):
-
                         raise
 
                 match = re.match(r"^(.*)-(\d+)$", alias)
diff --git a/aleksis/apps/matrix/tests/synapse/homeserver.yaml b/aleksis/apps/matrix/tests/synapse/homeserver.yaml
index 9221c000737b21d86de9e3ea7302e82e53baf7a9..892eb96694019c3fcc21a706594243b2eb777be7 100644
--- a/aleksis/apps/matrix/tests/synapse/homeserver.yaml
+++ b/aleksis/apps/matrix/tests/synapse/homeserver.yaml
@@ -8,10 +8,10 @@ listeners:
     tls: false
     type: http
     x_forwarded: true
-    bind_addresses: [ '::1', '127.0.0.1' ]
+    bind_addresses: ["::1", "127.0.0.1"]
 
     resources:
-      - names: [ client, federation ]
+      - names: [client, federation]
         compress: false
 
 database:
diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py
index b1cc47de7e3c77681cce5e219d801818628be2e6..a815655fe4d290d4bbf9fa8fd6daacc11386633b 100644
--- a/aleksis/apps/matrix/tests/test_matrix.py
+++ b/aleksis/apps/matrix/tests/test_matrix.py
@@ -25,7 +25,6 @@ SERVER_URL = "http://127.0.0.1:8008"
 
 
 def test_connection(synapse):
-
     assert synapse["listeners"][0]["port"] == 8008
 
     assert requests.get(SERVER_URL).status_code == requests.codes.ok
@@ -33,7 +32,6 @@ def test_connection(synapse):
 
 @pytest.fixture
 def matrix_bot_user(synapse):
-
     body = {"username": "aleksis-bot", "password": "test", "auth": {"type": "m.login.dummy"}}
 
     get_site_preferences()["matrix__homeserver"] = SERVER_URL
@@ -72,7 +70,6 @@ def test_create_room_for_group(matrix_bot_user):
 
 #
 def test_create_room_for_group_short_name(matrix_bot_user):
-
     g = Group.objects.create(name="Test Room", short_name="test")
     assert not MatrixRoom.objects.all().exists()
     room = MatrixRoom.from_group(g)
@@ -254,7 +251,6 @@ def test_power_levels(matrix_bot_user):
 
 
 def test_sync_room_members_without_user(matrix_bot_user):
-
     get_site_preferences()["matrix__homeserver_ids"] = "matrix.aleksis.example.org"
 
     g = Group.objects.create(name="Test Room")
@@ -278,7 +274,6 @@ def test_sync_room_members_without_user(matrix_bot_user):
 
 
 def test_sync_room_members_without_homeserver(matrix_bot_user):
-
     get_site_preferences()["matrix__homeserver_ids"] = ""
 
     g = Group.objects.create(name="Test Room")
@@ -463,7 +458,6 @@ def test_too_much_invites(matrix_bot_user):
 
     persons = []
     for i in range(100):
-
         u = User.objects.create_user(f"test{i}", f"test{i}@example.org", f"test{i}")
         p = Person.objects.create(first_name=f"Test {i}", last_name="Person", user=u)
         persons.append(p)
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/docs/conf.py b/docs/conf.py
index be9800f0e53744ba0f9fa0dcd6d9294d9fde541a..61afe38653fb08177eead5e0d215bd0ba8f912f3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -29,9 +29,9 @@ copyright = "2018-2023 The AlekSIS team"
 author = "The AlekSIS Team"
 
 # The short X.Y version
-version = "3.0"
+version = "2.0"
 # The full version, including alpha/beta/rc tags
-release = "3.0.0.dev0"
+release = "2.0.1.dev0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/fixtures/example_data.yaml b/fixtures/example_data.yaml
index 9fa7bee01d298979955fc7285411528ed630746b..64fe88d9b11996953276ded9a5d00071181c21ea 100644
--- a/fixtures/example_data.yaml
+++ b/fixtures/example_data.yaml
@@ -122,76 +122,76 @@
     site: 1
     name: "8c"
     short_name: "8c"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 102
   fields:
     site: 1
     name: "5c"
     short_name: "5c"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 103
   fields:
     site: 1
     name: "6c"
     short_name: "6c"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 104
   fields:
     site: 1
     name: "5a"
     short_name: "5a"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 105
   fields:
     site: 1
     name: "7a"
     short_name: "7a"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 106
   fields:
     site: 1
     name: "6a"
     short_name: "6a"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
 - model: core.group
   pk: 107
   fields:
     site: 1
     name: "9d"
     short_name: "9d"
-    members: [ 6, 7, 8 ]
+    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 ]
+    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 ]
+    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 ]
+    members: [6, 7, 8]
+    owners: [3]
+    parent_groups: [103]
 - model: core.group
   pk: 4
   fields:
@@ -199,56 +199,56 @@
     name: "5a:De"
     short_name: "5a:De"
 
-    members: [ 6, 7, 8 ]
-    owners: [ 4 ]
-    parent_groups: [ 104 ]
+    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 ]
+    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 ]
+    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 ]
+    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 ]
+    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 ]
+    members: [2, 3, 4, 5]
 - model: core.group
   pk: 10
   fields:
     site: 1
     name: "Students"
     short_name: "Students"
-    members: [ 6, 7, 8 ]
+    members: [6, 7, 8]
diff --git a/pyproject.toml b/pyproject.toml
index dadf61c64545d011c9cb74c6700c8472ff8fbfca..5ef0db94a3be167b8a903970f302a7f512f15151 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-App-Matrix"
-version = "3.0.0.dev0"
+version = "2.0.1.dev0"
 packages = [
     { include = "aleksis" }
 ]
@@ -17,7 +17,7 @@ license = "EUPL-1.2-or-later"
 homepage = "https://aleksis.org"
 repository = "https://edugit.org/AlekSIS/official/AlekSIS-App-Matrix"
 classifiers = [
-    "Development Status :: 4 - Beta",
+    "Development Status :: 5 - Production/Stable",
     "Environment :: Web Environment",
     "Framework :: Django :: 3.0",
     "Intended Audience :: Education",
@@ -32,10 +32,10 @@ secondary = true
 
 [tool.poetry.dependencies]
 python = "^3.9"
-aleksis-core = "^3.0.dev3"
+aleksis-core = "^3.0"
 
 [tool.poetry.dev-dependencies]
-aleksis-builddeps = "*"
+aleksis-builddeps = ">=2023.1.dev0"
 matrix-synapse = "^1.49.2"
 pytest-xprocess = "^0.19.0"
 pytest-mock = "^3.7.0"
diff --git a/tox.ini b/tox.ini
index 749e0606f4f02fcbd1649627219b15850cbc0a90..6e4b77ab1ded935257117696975c2150772cd85c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,16 +1,15 @@
 [tox]
 skipsdist = True
 skip_missing_interpreters = true
-envlist = py37,py38,py39
+envlist = py39,py310,py311
 
 [testenv]
 whitelist_externals = poetry
-		      sudo
 skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install
-     poetry run aleksis-admin yarn install
+     poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/
@@ -27,6 +26,8 @@ commands =
     poetry run black --check --diff aleksis/
     poetry run isort -c --diff --stdout aleksis/
     poetry run flake8 {posargs} aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --check --ignore-path={toxinidir}/.prettierignore {toxinidir}"
+    poetry run sh -c "aleksis-admin yarn run eslint {toxinidir}/aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=."
 
 [testenv:security]
 commands =
@@ -46,6 +47,7 @@ commands = poetry run make -C docs/ html {posargs}
 commands =
     poetry run isort aleksis/
     poetry run black aleksis/
+    poetry run sh -c "aleksis-admin yarn run prettier --write --ignore-path={toxinidir}/.prettierignore {toxinidir}"
 
 [testenv:makemessages]
 commands =