diff --git a/.eslintrc.js b/.eslintrc.js
index 4c2043012828bd16438eb4f36472ad48460eb6e4..3bdc1f231b3a75f10ae3ba0687e8c649d7218082 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -9,7 +9,6 @@ module.exports = {
     "vue/multi-word-component-names": "off",
   },
   env: {
-    browser: true,
-    node: true,
+    es2021: true,
   },
 };
diff --git a/.gitignore b/.gitignore
index dd85b53f78b12d78a2ff1053f4ab17e2601a62b2..9f60735a5e85703360c8669a34fa01622a079afd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,5 @@ yarn.lock
 *.code-workspace
 
 /cache
+/node_modules
+.vite
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 22864314148d1bada0cccb4ef5f828235a9b8cd5..6efe90b75c7e1ae7ba4a387bcc08417172eab585 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,12 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Deprecated
+~~~~~~~~~~
+
+* The `webpack_bundle` management command is replaced by the new `vite`
+  command. The `webpack_bundle` command will be removed in AlekSIS-Core 4.0.
+
 Added
 ~~~~~
 
@@ -19,6 +25,8 @@ Changed
 ~~~~~~~
 
 * Rewrite of frontend using Vuetify
+  * The runuwsgi dev server now starts a Vite dev server with HMR in the
+    background
 * OIDC scope "profile" now exposes the avatar instead of the official photo
 * Based on Django 4.0
   * Use built-in Redis cache backend
@@ -27,6 +35,7 @@ Changed
   requests
 * Incorporate SPDX license list for app licenses on About page
 * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check`
+* Frontend bundling migrated from Webpack to Vite
 
 Fixed
 ~~~~~
diff --git a/Dockerfile b/Dockerfile
index 914ee330f0d7e6646ba62c4d630747501b72d4f4..44159bb3fd185d4eb11224e48139319c9468b019 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -75,7 +75,7 @@ CMD ["/usr/local/bin/aleksis-docker-startup"]
 
 # Install assets
 FROM core as assets
-RUN eatmydata aleksis-admin webpack_bundle; \
+RUN eatmydata aleksis-admin vite build; \
     eatmydata aleksis-admin collectstatic --no-input; \
     rm -rf /usr/local/share/.cache
 # FIXME Introduce deletion after we don't need materializecss anymore for SASS
@@ -124,7 +124,7 @@ ONBUILD RUN set -e; \
             if [ -n "$APPS" ]; then \
                 eatmydata pip install $APPS; \
             fi; \
-            eatmydata aleksis-admin webpack_bundle; \
+            eatmydata aleksis-admin vite build; \
             eatmydata aleksis-admin collectstatic --no-input; \
             rm -rf /usr/local/share/.cache; \
             eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js
index e34a49bc0b6c914743524323f5c9a5e5532e26f9..4c3830acb049121f5afe3105c60902799e9b5e23 100644
--- a/aleksis/core/assets/app.js
+++ b/aleksis/core/assets/app.js
@@ -34,16 +34,24 @@ const apolloClient = new ApolloClient({
   uri: window.location.origin + "/django/graphql/",
 });
 
-const apolloProvider = new VueApollo({
-  defaultClient: apolloClient,
-});
-
 import App from "./App.vue";
-
+import CacheNotification from "./components/CacheNotification.vue";
+import LanguageForm from "./components/LanguageForm.vue";
 import MessageBox from "./components/MessageBox.vue";
+import NotificationList from "./components/notifications/NotificationList.vue";
+import SidenavSearch from "./components/SidenavSearch.vue";
+import CeleryProgressBottom from "./components/celery_progress/CeleryProgressBottom.vue";
+import gqlSystemProperties from "./systemProperties.graphql";
 
 Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
 
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+  defaultClient: apolloClient,
+});
+
+
 const router = new VueRouter({
   mode: "history",
 });
@@ -61,6 +69,22 @@ const app = new Vue({
     // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere
     showCacheAlert: false,
   }),
+  apollo: {
+    systemProperties: gqlSystemProperties,
+  },
+  watch: {
+    systemProperties: function (newProperties) {
+      this.$i18n.locale = newProperties.currentLanguage;
+      this.$vuetify.lang.current = newProperties.currentLanguage;
+    },
+  },
+  components: {
+    "cache-notification": CacheNotification,
+    "language-form": LanguageForm,
+    "notification-list": NotificationList,
+    "sidenav-search": SidenavSearch,
+    CeleryProgressBottom,
+  },
   router,
   i18n,
 });
diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue
index 58bf7bf6d718302279cd9d2d5d9451b6bdf9a644..ea044a9b6a0bca4ed7a84fdcc4ae4ef156826e87 100644
--- a/aleksis/core/assets/components/SidenavSearch.vue
+++ b/aleksis/core/assets/components/SidenavSearch.vue
@@ -1,4 +1,6 @@
 <script>
+import gqlSearchSnippets from "./searchSnippets.graphql";
+
 export default {
   methods: {
     submit: function () {
@@ -22,7 +24,7 @@ export default {
 
 <template>
   <ApolloQuery
-    :query="require('./searchSnippets.graphql')"
+    :query="gqlSearchSnippets"
     :variables="{
       q,
     }"
diff --git a/aleksis/core/assets/components/about/InstalledAppsList.vue b/aleksis/core/assets/components/about/InstalledAppsList.vue
index 491e3df15ece49cf736f037212a0bc668a924bed..704eedc97e172f7f840f4023d0a472a0d2fc7907 100644
--- a/aleksis/core/assets/components/about/InstalledAppsList.vue
+++ b/aleksis/core/assets/components/about/InstalledAppsList.vue
@@ -1,5 +1,5 @@
 <template>
-  <ApolloQuery :query="require('./installedApps.graphql')">
+  <ApolloQuery :query="gqlInstalledApps">
     <template #default="{ result: { error, data }, isLoading }">
       <v-row v-if="isLoading">
         <v-col
@@ -31,6 +31,7 @@
 
 <script>
 import InstalledAppCard from "./InstalledAppCard.vue";
+import gqlInstalledApps from "./installedApps.graphql";
 
 export default {
   name: "InstalledAppsList",
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
index 06b28fc37f52f5440442815207d901fe42cdcd69..86cc128471428a50f474a28d38ea19925e9b6d04 100644
--- a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
@@ -86,13 +86,15 @@
 <script>
 import BackButton from "../BackButton.vue";
 import MessageBox from "../MessageBox.vue";
+import gqlCeleryProgress from "./celeryProgress.graphql";
+import gqlCeleryProgressFetched from "./celeryProgressFetched.graphql";
 
 export default {
   name: "CeleryProgress",
   components: { BackButton, MessageBox },
   apollo: {
     celeryProgressByTaskId: {
-      query: require("./celeryProgress.graphql"),
+      query: gqlCeleryProgress,
       variables() {
         return {
           taskId: this.$route.params.taskId,
@@ -114,7 +116,7 @@ export default {
       if (newState === "SUCCESS" || newState === "ERROR") {
         this.$apollo.queries.celeryProgressByTaskId.stopPolling();
         this.$apollo.mutate({
-          mutation: require("./celeryProgressFetched.graphql"),
+          mutation: gqlCeleryProgressFetched,
           variables: {
             taskId: this.$route.params.taskId,
           },
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
index 2bb95431cecd66bc68789bc1bfe7c5977b68cfb6..410e57fda0eca36d3acaca7fc0318174fd71d318 100644
--- a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
@@ -25,6 +25,7 @@
 
 <script>
 import TaskListItem from "./TaskListItem.vue";
+import gqlCeleryProgressButton from "./celeryProgressBottom.graphql";
 
 export default {
   name: "CeleryProgressBottom",
@@ -45,7 +46,7 @@ export default {
   },
   apollo: {
     celeryProgressByUser: {
-      query: require("./celeryProgressBottom.graphql"),
+      query: gqlCeleryProgressButton,
       pollInterval: 1000,
     },
   },
diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue
index b1dea65c167df9417803a6e7c532309acce75325..6011765afe2ebf3df707d2dbfe7549e4f010d4a8 100644
--- a/aleksis/core/assets/components/notifications/NotificationItem.vue
+++ b/aleksis/core/assets/components/notifications/NotificationItem.vue
@@ -1,6 +1,6 @@
 <template>
   <ApolloMutation
-    :mutation="require('./markNotificationRead.graphql')"
+    :mutation="gqlmarkNotificationRead"
     :variables="{ id: this.notification.id }"
   >
     <template #default="{ mutate, loading, error }">
@@ -80,6 +80,8 @@
 </template>
 
 <script>
+import gqlMarkNotificationRead from "./markNotificationRead.graphql";
+
 export default {
   props: {
     notification: {
diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue
index 75493f837c9ba6a799c1e646d90fb4b5478cbcd0..b0e4a50a01112186da8627200bfab410215379bf 100644
--- a/aleksis/core/assets/components/notifications/NotificationList.vue
+++ b/aleksis/core/assets/components/notifications/NotificationList.vue
@@ -1,6 +1,6 @@
 <template>
   <ApolloQuery
-    :query="require('./myNotifications.graphql')"
+    :query="gqlMyNotifications"
     :poll-interval="1000"
   >
     <template #default="{ result: { error, data, loading } }">
@@ -74,6 +74,7 @@
 
 <script>
 import NotificationItem from "./NotificationItem.vue";
+import gqlMyNotifications from "./myNotifications.graphql";
 
 export default {
   components: {
diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js
index 0cc741d08ec259cdde64be42daece4646b816cae..a761522fcc74fda66d8207749aad58d7c6b4d423 100644
--- a/aleksis/core/assets/index.js
+++ b/aleksis/core/assets/index.js
@@ -5,9 +5,17 @@ import "./app";
 
 import LegacyBaseTemplate from "./components/LegacyBaseTemplate.vue";
 import Parent from "./components/Parent.vue";
+
+// This imports all known AlekSIS app entrypoints
+// The list is generated by util/frontent_helpers.py and passed to Vite,
+//  which aliases the app package names into virtual JavaScript modules
+//  and generates importing code at bundle time.
+import "aleksisAppImporter";
+
 import CeleryProgress from "./components/celery_progress/CeleryProgress.vue";
 import About from "./components/about/About.vue";
 
+
 window.router.addRoute({
   path: "/account/login/",
   name: "core.account.login",
diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py
new file mode 100644
index 0000000000000000000000000000000000000000..57370441b37a9db241a8a8a0bebbd8f8f00f5753
--- /dev/null
+++ b/aleksis/core/management/commands/vite.py
@@ -0,0 +1,29 @@
+import os
+
+from django.conf import settings
+
+from django_yarnpkg.management.base import BaseYarnCommand
+from django_yarnpkg.yarn import yarn_adapter
+
+from ...util.frontend_helpers import run_vite, write_vite_values
+
+
+class Command(BaseYarnCommand):
+    help = "Create Vite bundles for AlekSIS"  # noqa
+
+    def add_arguments(self, parser):
+        parser.add_argument("command", choices=["build", "serve"], nargs="?", default="build")
+        parser.add_argument("--no-install", action="store_true", default=False)
+
+    def handle(self, *args, **options):
+        super(Command, self).handle(*args, **options)
+
+        # Inject settings into Vite
+        write_vite_values(os.path.join(settings.NODE_MODULES_ROOT, "django-vite-values.json"))
+
+        # Install Node dependencies
+        if not options["no_install"]:
+            yarn_adapter.install(settings.YARN_INSTALLED_APPS)
+
+        # Run Vite build
+        run_vite([options["command"]])
diff --git a/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/webpack_bundle.py
index ee38566a8cda8587c41aacf036eca1d7da16cd04..1b324ae200e993dba15119fb16335091217d7200 100644
--- a/aleksis/core/management/commands/webpack_bundle.py
+++ b/aleksis/core/management/commands/webpack_bundle.py
@@ -1,35 +1,16 @@
-import json
-import os
-import shutil
+import warnings
 
-from django.conf import settings
+from .vite import Command as ViteCommand
 
-from django_yarnpkg.management.base import BaseYarnCommand
-from django_yarnpkg.yarn import yarn_adapter
 
-from ...util.frontend_helpers import get_apps_with_assets
-
-
-class Command(BaseYarnCommand):
-    help = "Create webpack bundles for AlekSIS"  # noqa
+class Command(ViteCommand):
+    help = "Create Vite bundles for AlekSIS (legacy command alias)"  # noqa
 
     def handle(self, *args, **options):
-        super(Command, self).handle(*args, **options)
-
-        # Write webpack entrypoints for all apps
-        assets = {
-            app: {"dependOn": "core", "import": os.path.join(path, "index")}
-            for app, path in get_apps_with_assets().items()
-        }
-        assets["core"] = os.path.join(settings.BASE_DIR, "aleksis", "core", "assets", "index")
-        with open(os.path.join(settings.NODE_MODULES_ROOT, "webpack-entrypoints.json"), "w") as out:
-            json.dump(assets, out)
-
-        # Install Node dependencies
-        yarn_adapter.install(settings.YARN_INSTALLED_APPS)
+        warnings.warn(
+            "webpack_bundle is deprecated and will be removed "
+            "in AlekSIS-Core 4.0. Use the new vite command instead.",
+            UserWarning,
+        )
 
-        # Run webpack
-        config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js")
-        shutil.copy(config_path, settings.NODE_MODULES_ROOT)
-        mode = "development" if settings.DEBUG else "production"
-        yarn_adapter.call_yarn(["run", "webpack", f"--mode={mode}"])
+        super().handle(*args, **options)
diff --git a/aleksis/core/migrations/0047_add_room_model.py b/aleksis/core/migrations/0047_add_room_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..2194017cf1678ab8c5bc4af61b7e7ed05e4cacf3
--- /dev/null
+++ b/aleksis/core/migrations/0047_add_room_model.py
@@ -0,0 +1,55 @@
+# Generated by Django 3.2.15 on 2022-11-20 14:20
+
+from django.apps import apps
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('core', '0046_notification_create_field_icon'),
+    ]
+
+    if "chronos" in apps.app_configs:
+        recorder = migrations.recorder
+        if not recorder.MigrationRecorder.Migration.objects.filter(app="core", name="0046_add_room_model").exists():
+            dependencies.append(('chronos', '0012_add_supervision_global_permission'))
+
+    operations = [
+        migrations.CreateModel(
+            name='Room',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('short_name', models.CharField(max_length=255, verbose_name='Short name')),
+                ('name', models.CharField(max_length=255, verbose_name='Long name')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'verbose_name': 'Room',
+                'verbose_name_plural': 'Rooms',
+                'ordering': ['name', 'short_name'],
+                'permissions': (('view_room_timetable', 'Can view room timetable'),),
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='room',
+            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_room_short_name_per_site'),
+        ),
+        # Migrate data from Chronos table; deletion will be handled by Chronos
+        migrations.RunSQL(
+            """
+            -- Copy rooms from chronos if table exists
+            DO $$BEGIN INSERT INTO core_room SELECT * FROM chronos_room; EXCEPTION WHEN undefined_table THEN NULL; END$$;
+            """
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 9bf3f0d12eb8bf7f9b9b7c03319647903acf6ea8..fff6d2cfa71a70b30d2f92a39f782ffc151c6927 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1465,3 +1465,25 @@ class PersonalICalUrl(models.Model):
 
     def get_absolute_url(self):
         return reverse("ical_feed", kwargs={"slug": self.uuid})
+
+
+class Room(ExtensibleModel):
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
+    name = models.CharField(verbose_name=_("Long name"), max_length=255)
+
+    def __str__(self) -> str:
+        return f"{self.name} ({self.short_name})"
+
+    def get_absolute_url(self) -> str:
+        return reverse("timetable", args=["room", self.id])
+
+    class Meta:
+        permissions = (("view_room_timetable", _("Can view room timetable")),)
+        ordering = ["name", "short_name"]
+        verbose_name = _("Room")
+        verbose_name_plural = _("Rooms")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["site_id", "short_name"], name="unique_room_short_name_per_site"
+            ),
+        ]
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index b2ab913d34b110bcd10819baee1da24e742f3d25..cd6ac2c6acf9a86b7999b36807762b4a04e0471f 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -9,7 +9,11 @@ from haystack.utils.loading import UnifiedIndex
 from ..models import Notification, Person, TaskUserAssignment
 from ..util.apps import AppConfig
 from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person
-from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
+from .celery_progress import (
+    CeleryProgressFetchedMutation,
+    CeleryProgressMetaType,
+    CeleryProgressType,
+)
 from .group import GroupType  # noqa
 from .installed_apps import AppType
 from .message import MessageType
@@ -44,15 +48,18 @@ class Query(graphene.ObjectType):
     messages = graphene.List(MessageType)
 
     def resolve_notifications(root, info, **kwargs):
-        # FIXME do permission stuff
-        return Notification.objects.all()
+        return NotificationType.get_queryset(
+            Notification.objects.all().order_by("-created"),
+            info,
+        )
 
     def resolve_persons(root, info, **kwargs):
-        # FIXME do permission stuff
-        return Person.objects.all()
+        return PersonType.get_queryset(Person.objects.all(), info).all()
 
     def resolve_person_by_id(root, info, id):  # noqa
-        return Person.objects.get(pk=id)
+        return PersonType.get_queryset(
+            Person.objects.filter(pk=id), info, "core.view_person_rule"
+        ).first()
 
     def resolve_who_am_i(root, info, **kwargs):
         if has_person(info.context.user):
@@ -70,10 +77,13 @@ class Query(graphene.ObjectType):
         return [app for app in apps.get_app_configs() if isinstance(app, AppConfig)]
 
     def resolve_celery_progress_by_task_id(root, info, task_id, **kwargs):
-        task = TaskUserAssignment.objects.get(task_result__task_id=task_id)
+        task = CeleryProgressMetaType.get_queryset(
+            TaskUserAssignment.objects.filter(task_result__task_id=task_id),
+            info,
+        ).first()
 
-        if not info.context.user.has_perm("core.view_progress_rule", task):
-            return None
+        if not task:
+            raise PermissionDenied()
         progress = task.get_progress_with_meta()
         return progress
 
diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..363c6b8ac849affcba2d9c1cbf9eefa74498121d
--- /dev/null
+++ b/aleksis/core/schema/base.py
@@ -0,0 +1,14 @@
+from graphene_django import DjangoObjectType
+
+from ..util.core_helpers import queryset_rules_filter
+
+
+class RulesObjectType(DjangoObjectType):
+    class Meta:
+        abstract = True
+
+    @classmethod
+    def get_queryset(cls, queryset, info, perm):
+        q = super().get_queryset(queryset, info)
+
+        return queryset_rules_filter(info.context, q, perm)
diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py
index 941d18f1d5e79db17a549d30dbb90f0c42310a25..c6fe93dd06b8e853d6d0b3f748ad1d6b2b4534b5 100644
--- a/aleksis/core/schema/celery_progress.py
+++ b/aleksis/core/schema/celery_progress.py
@@ -2,9 +2,9 @@ from django.contrib.messages.constants import DEFAULT_TAGS
 
 import graphene
 from graphene import ObjectType
-from graphene_django import DjangoObjectType
 
 from ..models import TaskUserAssignment
+from .base import RulesObjectType
 
 
 class CeleryProgressMessage(ObjectType):
@@ -28,7 +28,7 @@ class CeleryProgressAdditionalButtonType(ObjectType):
     icon = graphene.String()
 
 
-class CeleryProgressMetaType(DjangoObjectType):
+class CeleryProgressMetaType(RulesObjectType):
     additional_button = graphene.Field(CeleryProgressAdditionalButtonType, required=False)
     task_id = graphene.String(required=True)
 
@@ -47,6 +47,10 @@ class CeleryProgressMetaType(DjangoObjectType):
             "additional_button",
         )
 
+    @classmethod
+    def get_queryset(cls, queryset, info, perm="core.view_progress_rule"):
+        return super().get_queryset(queryset, info, perm)
+
     def resolve_additional_button(root, info, **kwargs):
         if not root.additional_button_title or not root.additional_button_url:
             return None
diff --git a/aleksis/core/schema/group.py b/aleksis/core/schema/group.py
index 327daff3dd09d54053b683ba4cefb71da8ba6e5b..70f00bd1d6793916a9c954747e6242a09a12b519 100644
--- a/aleksis/core/schema/group.py
+++ b/aleksis/core/schema/group.py
@@ -1,8 +1,11 @@
-from graphene_django import DjangoObjectType
-
 from ..models import Group
+from .base import RulesObjectType
 
 
-class GroupType(DjangoObjectType):
+class GroupType(RulesObjectType):
     class Meta:
         model = Group
+
+    @classmethod
+    def get_queryset(cls, queryset, info, perm="core.view_groups_rule"):
+        return super().get_queryset(queryset, info, perm)
diff --git a/aleksis/core/schema/notification.py b/aleksis/core/schema/notification.py
index 114f92b32d9658208013144fe2d7c2d2b0157595..fe1fc19a499f7241fd2770a6a3d1f394bd4569b6 100644
--- a/aleksis/core/schema/notification.py
+++ b/aleksis/core/schema/notification.py
@@ -1,13 +1,17 @@
 import graphene
-from graphene_django import DjangoObjectType
 
 from ..models import Notification
+from .base import RulesObjectType
 
 
-class NotificationType(DjangoObjectType):
+class NotificationType(RulesObjectType):
     class Meta:
         model = Notification
 
+    @classmethod
+    def get_queryset(cls, queryset, info, perm="core.view_notifications_rule"):
+        return super().get_queryset(queryset, info, perm)
+
 
 class MarkNotificationReadMutation(graphene.Mutation):
     class Arguments:
@@ -18,8 +22,10 @@ class MarkNotificationReadMutation(graphene.Mutation):
     @classmethod
     def mutate(cls, root, info, id):  # noqa
         notification = Notification.objects.get(pk=id)
-        # FIXME permissions
+
+        if not info.context.user.has_perm("core.mark_notification_as_read_rule", notification):
+            raise PermissionDenied()
         notification.read = True
         notification.save()
 
-        return notification
+        return MarkNotificationReadMutation(notification=notification)
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 2ab8918900e64571a282674ca1e9b326204ddd34..044d5922c989655f20e355f6904eb3f2292986a6 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -3,11 +3,11 @@ from typing import Union
 from django.utils import timezone
 
 import graphene
-from graphene_django import DjangoObjectType
 from graphene_django.forms.mutation import DjangoModelFormMutation
 
 from ..forms import PersonForm
 from ..models import DummyPerson, Person
+from .base import RulesObjectType
 
 
 class FieldFileType(graphene.ObjectType):
@@ -24,7 +24,7 @@ class PersonPreferencesType(graphene.ObjectType):
         return parent["theme__design"]
 
 
-class PersonType(DjangoObjectType):
+class PersonType(RulesObjectType):
     class Meta:
         model = Person
 
@@ -58,9 +58,23 @@ class PersonType(DjangoObjectType):
     def resolve_notifications(root: Person, info, **kwargs):
         return root.notifications.filter(send_at__lte=timezone.now()).order_by("read", "-created")
 
+    @classmethod
+    def get_queryset(cls, queryset, info, perm="core.view_persons_rule"):
+        return super().get_queryset(queryset, info, perm)
+
 
 class PersonMutation(DjangoModelFormMutation):
     person = graphene.Field(PersonType)
 
     class Meta:
         form_class = PersonForm
+
+    @classmethod
+    def perform_mutate(cls, form, info):
+        if form.initial:
+            if not info.context.user.has_perm("core.create_person.rule"):
+                raise PermissionDenied()
+        else:
+            if not info.context.user.has_perm("core.edit_person.rule", form.instance):
+                raise PermissionDenied()
+        return super().perform_mutate(form, info)
diff --git a/aleksis/core/search_indexes.py b/aleksis/core/search_indexes.py
index 7583a774eaadebb1cbfdb35d08dc0ddcfb355eea..ea0b4dae56290791bf9f99c919aa6d2d7925b64e 100644
--- a/aleksis/core/search_indexes.py
+++ b/aleksis/core/search_indexes.py
@@ -1,4 +1,4 @@
-from .models import Group, Person
+from .models import Group, Person, Room
 from .util.search import Indexable, SearchIndex
 
 
@@ -12,3 +12,9 @@ class GroupIndex(SearchIndex, Indexable):
     """Haystack index for searching groups."""
 
     model = Group
+
+
+class RoomIndex(SearchIndex, Indexable):
+    """Haystack index for searching rooms."""
+
+    model = Room
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 499ca24baacd1bf11308dd8ea33749ecc30bfaeb..119325431a67baef0abe0f0d9cf923557e39b45b 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -97,7 +97,7 @@ INSTALLED_APPS = [
     "sass_processor",
     "django_any_js",
     "django_yarnpkg",
-    "webpack_loader",
+    "django_vite",
     "django_tables2",
     "maintenance_mode",
     "menu_generator",
@@ -572,22 +572,17 @@ YARN_INSTALLED_APPS = [
     "vue-apollo@^3.1.0",
     "vuetify@^2.6.7",
     "vue-router@^3.5.2",
-    "css-loader@^6.7.1",
-    "sass-loader@^13.0",
-    "vue-loader@^15.0.0",
-    "vue-style-loader@^4.1.3",
-    "vue-template-compiler@^2.7.7",
-    "webpack@^5.73.0",
-    "webpack-bundle-tracker@^1.6.0",
-    "webpack-cli@^4.10.0",
+    "vite@^4.0.1",
+    "@vitejs/plugin-vue2@^2.2.0",
+    "@rollup/plugin-node-resolve@^15.0.1",
+    "@rollup/plugin-graphql@^2.0.2",
+    "@rollup/plugin-virtual@^3.0.1",
     "vue-i18n@8",
     "eslint@^8.26.0",
     "eslint-plugin-vue@^9.7.0",
-    "eslint-webpack-plugin@^3.2.0",
     "eslint-config-prettier@^8.5.0",
     "stylelint@^14.14.0",
     "stylelint-config-standard@^29.0.0",
-    "stylelint-webpack-plugin@^3.3.0",
     "stylelint-config-prettier@^9.0.3",
 ]
 
@@ -596,17 +591,12 @@ merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
 JS_URL = _settings.get("js_assets.url", STATIC_URL)
 JS_ROOT = _settings.get("js_assets.root", os.path.join(NODE_MODULES_ROOT, "node_modules"))
 
-WEBPACK_LOADER = {
-    "DEFAULT": {
-        "CACHE": not DEBUG,
-        "STATS_FILE": os.path.join(NODE_MODULES_ROOT, "webpack-stats.json"),
-        "BUNDLE_DIR_NAME": "",
-        "POLL_INTERVAL": 0.1,
-        "IGNORE": [r".+\.hot-update.js", r".+\.map"],
-    }
-}
+DJANGO_VITE_ASSETS_PATH = os.path.join(NODE_MODULES_ROOT, "vite_bundles")
+DJANGO_VITE_DEV_MODE = DEBUG
+DJANGO_VITE_DEV_SERVER_PORT = 5173
+
 STATICFILES_DIRS = (
-    os.path.join(NODE_MODULES_ROOT, "webpack_bundles"),
+    DJANGO_VITE_ASSETS_PATH,
     JS_ROOT,
 )
 
@@ -755,6 +745,7 @@ if _settings.get("dev.uwsgi.celery", DEBUG):
     UWSGI.setdefault("attach-daemon", [])
     UWSGI["attach-daemon"].append(f"celery -A aleksis.core worker --concurrency={concurrency}")
     UWSGI["attach-daemon"].append("celery -A aleksis.core beat")
+    UWSGI["attach-daemon"].append("aleksis-admin vite --no-install serve")
 
 DEFAULT_FAVICON_PATHS = {
     "pwa_icon": os.path.join(STATIC_ROOT, "img/aleksis-icon-maskable.png"),
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index f79c4a9bd7dfde9bcfcb5a2f822da5643054dca4..c1383da534105e30ccbb3ac09e67e35e1e80df15 100644
--- a/aleksis/core/templates/core/vue_base.html
+++ b/aleksis/core/templates/core/vue_base.html
@@ -1,7 +1,7 @@
 {# -*- engine:django -*- #}
 
 {% load i18n menu_generator static sass_tags any_js rules html_helpers %}
-{% load render_bundle from webpack_loader %}
+{% load django_vite %}
 {% get_current_language as LANGUAGE_CODE %}
 {% get_available_languages as LANGUAGES %}
 
@@ -53,6 +53,8 @@
   <script type="text/javascript" src="{% url 'config.js' %}"></script>
   {% include_js "iconify" %}
 
+  {% vite_hmr_client %}
+
   {% block extra_head %}{% endblock %}
 </head>
 <body {% if no_menu %}class="without-menu"{% endif %}>
@@ -209,7 +211,7 @@
 {{ request.site.preferences.theme__primary|json_script:"primary-color" }}
 {{ request.site.preferences.theme__secondary|json_script:"secondary-color" }}
 <script type="text/javascript" src="{% static 'js/search.js' %}"></script>
-{% render_bundle 'core' %}
+{% vite_asset 'aleksis/core/assets/index.js' %}
 {% block extra_body %}{% endblock %}
 </body>
 </html>
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 37741bc62b5310d13a1a512d47cf4cd7f4125a90..358c092b7549892616c04996a0164941da84c840 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -9,6 +9,7 @@ from django.views.i18n import JavaScriptCatalog
 
 import calendarweek.django
 from ckeditor_uploader import views as ckeditor_uploader_views
+from graphene_django.views import GraphQLView
 from health_check.urls import urlpatterns as health_urls
 from oauth2_provider.views import ConnectDiscoveryInfoView
 from rules.contrib.views import permission_required
diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py
index 664707500bda7ffde9d5d8456be39128cf55ff8f..4389cdd6d50b6651f12e0d20d904849195a94156 100644
--- a/aleksis/core/util/frontend_helpers.py
+++ b/aleksis/core/util/frontend_helpers.py
@@ -1,7 +1,12 @@
+import json
 import os
+import shutil
+from typing import Any, Optional, Sequence
 
 from django.conf import settings
 
+from django_yarnpkg.yarn import yarn_adapter
+
 from .core_helpers import get_app_module, get_app_packages
 
 
@@ -17,6 +22,43 @@ def get_apps_with_assets():
     return assets
 
 
+def write_vite_values(out_path: str) -> dict[str, Any]:
+    vite_values = {
+        "static_url": settings.STATIC_URL,
+    }
+    # Write rollup entrypoints for all apps
+    vite_values["appEntrypoints"] = {}
+    for app, path in get_apps_with_assets().items():
+        ep = os.path.join(path, "index.js")
+        if os.path.exists(ep):
+            vite_values["appEntrypoints"][app] = ep
+    # Add core entrypoint
+    vite_values["coreEntrypoint"] = os.path.join(
+        settings.BASE_DIR, "aleksis", "core", "assets", "index.js"
+    )
+
+    with open(out_path, "w") as out:
+        json.dump(vite_values, out)
+
+
+def run_vite(args: Optional[Sequence[str]] = None) -> None:
+    args = list(args) if args else []
+
+    config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js")
+    shutil.copy(config_path, settings.NODE_MODULES_ROOT)
+
+    mode = "development" if settings.DEBUG else "production"
+    args += ["-m", mode]
+
+    log_level = settings.LOGGING["root"]["level"]
+    if settings.DEBUG or log_level == "DEBUG":
+        args.append("-d")
+    log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent")
+    args += ["-l", log_level]
+
+    yarn_adapter.call_yarn(["run", "vite"] + args)
+
+
 def get_language_cookie(code: str) -> str:
     """Build a cookie string to set a new language."""
     cookie_parts = [f"{settings.LANGUAGE_COOKIE_NAME}={code}"]
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index cabc9e1413209d83c184d6ae00b86e40e56cb2e1..6faf412f8e57d7a5e3455510a2c82728696997bf 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -44,7 +44,6 @@ from django_celery_results.models import TaskResult
 from django_filters.views import FilterView
 from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView
 from dynamic_preferences.forms import preference_form_builder
-from graphene_django.views import GraphQLView
 from guardian.shortcuts import GroupObjectPermission, UserObjectPermission, get_objects_for_user
 from haystack.generic_views import SearchView
 from haystack.inputs import AutoQuery
@@ -96,7 +95,6 @@ from .models import (
     DashboardWidget,
     DashboardWidgetOrder,
     DataCheckResult,
-    DummyPerson,
     Group,
     GroupType,
     OAuthApplication,
@@ -213,16 +211,23 @@ def index(request: HttpRequest) -> HttpResponse:
     if has_person(request.user):
         person = request.user.person
         widgets = person.dashboard_widgets
+        activities = person.activities.all().order_by("-created")[:5]
+        notifications = person.notifications.filter(send_at__lte=timezone.now()).order_by(
+            "-created"
+        )[:5]
+        unread_notifications = person.notifications.filter(
+            send_at__lte=timezone.now(), read=False
+        ).order_by("-created")
+        announcements = Announcement.objects.at_time().for_person(person)
+        activities = person.activities.all().order_by("-created")[:5]
+
     else:
-        person = DummyPerson()
+        person = None
+        activities = []
+        notifications = []
+        unread_notifications = []
         widgets = []
-
-    activities = person.activities.all().order_by("-created")[:5]
-
-    context["activities"] = activities
-
-    announcements = Announcement.objects.at_time().for_person(person)
-    context["announcements"] = announcements
+        announcements = []
 
     if len(widgets) == 0:
         # Use default dashboard if there are no widgets
@@ -235,6 +240,8 @@ def index(request: HttpRequest) -> HttpResponse:
     context["widgets"] = widgets
     context["media"] = media
     context["show_edit_dashboard_button"] = show_edit_dashboard_button
+    context["activities"] = activities
+    context["announcements"] = announcements
 
     return render(request, "core/index.html", context)
 
@@ -1589,7 +1596,3 @@ class ICalFeedCreateView(PermissionRequiredMixin, AdvancedCreateView):
         obj.person = self.request.user.person
         obj.save()
         return super().form_valid(form)
-
-
-class PrivateGraphQLView(LoginRequiredMixin, GraphQLView):
-    """GraphQL view that requires a valid user session."""
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..12a7bcc5baeec4f19645bc16bbfba556ace7f9f8
--- /dev/null
+++ b/aleksis/core/vite.config.js
@@ -0,0 +1,76 @@
+const fs = require("fs");
+const path = require("path");
+
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue2";
+import { nodeResolve } from "@rollup/plugin-node-resolve";
+import graphql from "@rollup/plugin-graphql";
+import virtual from "@rollup/plugin-virtual";
+
+const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json"));
+
+function generateAppImporter(entrypoints) {
+  let code = "let appObjects = {};";
+  for (const appPackage of Object.keys(entrypoints)) {
+    let appName = appPackage.split(".").slice(-1)[0];
+    appName = appName.charAt(0).toUpperCase() + appName.substring(1);
+
+    code += `console.debug("Importing AlekSIS app entrypoint for ${appPackage}");\n`;
+    code += `import ${appName} from '${appPackage}';\n`;
+    code += `appObjects.push(${appName});\n`;
+  }
+  code += "export default appObjects;\n";
+  return code;
+}
+
+export default defineConfig({
+  root: path.resolve(".."),
+  base: django_values.static_url,
+  build: {
+    outDir: path.resolve("./vite_bundles/"),
+    manifest: true,
+    rollupOptions: {
+      input: django_values.coreEntrypoint,
+      output: {
+        manualChunks(id) {
+          // Split big libraries into own chunks
+          if (id.includes("node_modules/vue")) {
+            return "vue";
+          } else if (id.includes("node_modules/apollo")) {
+            return "apollo";
+          } else if (id.includes("node_modules/graphql")) {
+            return "graphql";
+          } else if (id.includes("node_modules")) {
+            // Fallback for all other libraries
+            return "vendor";
+          }
+
+          // Split each AlekSIS app in its own chunk
+          for (const [appPackage, ep] of Object.entries(django_values.appEntrypoints)) {
+            if (id.includes(ep)) {
+              return appPackage;
+            }
+          }
+        },
+      },
+    },
+  },
+  server: {
+    strictPort: true,
+    origin: "http://127.0.0.1:5173",
+  },
+  plugins: [
+    virtual({
+      aleksisAppImporter: generateAppImporter(django_values.appEntrypoints),
+    }),
+    vue(),
+    nodeResolve({ modulePaths: [path.resolve("./node_modules")] }),
+    graphql(),
+  ],
+  resolve: {
+    alias: {
+      vue: "vue/dist/vue.esm.js",
+      ...django_values.appEntrypoints,
+    },
+  },
+});
diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js
deleted file mode 100644
index 8f327cbe55a6dba6b7ae5f104a89f4da46ddf711..0000000000000000000000000000000000000000
--- a/aleksis/core/webpack.config.js
+++ /dev/null
@@ -1,97 +0,0 @@
-const fs = require("fs");
-const path = require("path");
-const webpack = require("webpack");
-const BundleTracker = require("webpack-bundle-tracker");
-const { VueLoaderPlugin } = require("vue-loader");
-const ESLintPlugin = require("eslint-webpack-plugin");
-const StyleLintPlugin = require("stylelint-webpack-plugin");
-
-module.exports = {
-  context: __dirname,
-  entry: JSON.parse(fs.readFileSync("./webpack-entrypoints.json")),
-  output: {
-    path: path.resolve("./webpack_bundles/"),
-    filename: "[name]-[hash].js",
-    chunkFilename: "[id]-[chunkhash].js",
-  },
-  plugins: [
-    new BundleTracker({ filename: "./webpack-stats.json" }),
-    new VueLoaderPlugin(),
-    new ESLintPlugin({
-      extensions: ["js", "vue"],
-    }),
-    new StyleLintPlugin({
-      files: ["assets/**/*.{vue,htm,html,css,sss,less,scss,sass}"],
-    }),
-  ],
-  module: {
-    rules: [
-      {
-        test: /\.vue$/,
-        use: {
-          loader: "vue-loader",
-          options: {
-            transpileOptions: {
-              transforms: {
-                dangerousTaggedTemplateString: true,
-              },
-            },
-          },
-        },
-      },
-      {
-        test: /\.(css)$/,
-        use: ["vue-style-loader", "css-loader"],
-      },
-      {
-        test: /\.scss$/,
-        use: [
-          "vue-style-loader",
-          "css-loader",
-          {
-            loader: "sass-loader",
-            options: {
-              sassOptions: {
-                indentedSyntax: false,
-              },
-            },
-          },
-        ],
-      },
-      {
-        test: /\.(graphql|gql)$/,
-        exclude: /node_modules/,
-        loader: "graphql-tag/loader",
-      },
-    ],
-  },
-  optimization: {
-    runtimeChunk: "single",
-    splitChunks: {
-      chunks: "all",
-      maxInitialRequests: Infinity,
-      minSize: 0,
-      cacheGroups: {
-        vendor: {
-          test: /[\\/]node_modules[\\/]/,
-          name(module) {
-            // get the name. E.g. node_modules/packageName/not/this/part.js
-            // or node_modules/packageName
-            const packageName = module.context.match(
-              /[\\/]node_modules[\\/](.*?)([\\/]|$)/
-            )[1];
-
-            // npm package names are URL-safe, but some servers don't like @ symbols
-            return `npm.${packageName.replace("@", "")}`;
-          },
-        },
-      },
-    },
-  },
-  resolve: {
-    modules: [path.resolve("./node_modules")],
-    alias: {
-      vue$: "vue/dist/vue.esm.js",
-    },
-  },
-};
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36..322d39023183a171b29f2fffc4a55988441a89c7 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -144,7 +144,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`:
 
 .. code-block:: shell
    pip3 install aleksis
-   aleksis-admin webpack_bundle
+   aleksis-admin vite build
    aleksis-admin collectstatic
    aleksis-admin migrate
    aleksis-admin createinitialrevisions
diff --git a/pyproject.toml b/pyproject.toml
index c9a5f25b70fabae18809434113aeed5cdef4a1d7..0ab9b866ddfe0cf79255cb41de68747973452c0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -128,8 +128,8 @@ django-ical = "^1.8.3"
 django-iconify = "^0.3"
 customidenticon = "^0.1.5"
 graphene-django = "^3.0.0"
-django-webpack-loader = "^1.6.0"
 selenium = "^4.4.3"
+django-vite = "^2.0.2"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
diff --git a/tox.ini b/tox.ini
index ff7a8c4aa3f0d97c2224ac9e4998fdaa246265c7..0c422a187839c40b718427898e2de2fab1c80758 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,7 +11,7 @@ skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
      poetry install -E ldap
-     poetry run aleksis-admin webpack_bundle
+     poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/