diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fe7a89aa0ed37c4d3b6bf684618a0f2f1c5ac001..4fa7c521d2265157ca19e7e1863c7fba9e763858 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,23 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Changes
+~~~~~~~
+
+* The frontend is now able to display headings in the main toolbar.
+
+Fixed
+~~~~~
+
+* Default translations from vuetify were not loaded.
+* Browser locale was not the default locale in the entire frontend.
+* In some cases, some items in the sidenav menu were not shown due to its height being higher than the visible page area.
+* The search bar in the sidenav menu is shown even though the user has no permission to see it.
+* Add permission check to accept invitation menu point in order to hide it when this feature is disabled.
+* Metrics endpoint for Prometheus was at the wrong URL.
+* Polling behavior of the whoAmI and permission queries was fixed.
+* Confirmation e-mail contained a wrong link.
+
 `3.0`_ - 2022-05-11
 -------------------
 
@@ -19,6 +36,7 @@ Added
 * Provide API endpoint for system status.
 * [Dev] UpdateIndicator Vue Component to display the status of interactive pages
 * [Dev] DeleteDialog Vue Component to unify item deletion in the new frontend
+* Use build-in mechanism in Apollo for GraphQL batch querying.
 
 
 Changed
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index efd6e34edae2dd9f6e01e02cc9c8c9a669ce7cd0..c6c15e76465feb96363eb95f225549d0b65b3d92 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -160,7 +160,18 @@ class EditGroupForm(SchoolTermRelatedExtensibleForm):
 
     class Meta:
         model = Group
-        exclude = []
+        fields = [
+            "school_term",
+            "name",
+            "short_name",
+            "group_type",
+            "members",
+            "owners",
+            "parent_groups",
+            "additional_fields",
+            "photo",
+            "avatar",
+        ]
         widgets = {
             "members": ModelSelect2MultipleWidget(
                 search_fields=[
@@ -316,7 +327,16 @@ class AnnouncementForm(ExtensibleForm):
 
     class Meta:
         model = Announcement
-        exclude = []
+        fields = [
+            "valid_from_date",
+            "valid_from_time",
+            "valid_until_date",
+            "valid_until_time",
+            "groups",
+            "persons",
+            "title",
+            "description",
+        ]
 
 
 class ChildGroupsForm(forms.Form):
@@ -348,7 +368,7 @@ class EditAdditionalFieldForm(forms.ModelForm):
 
     class Meta:
         model = AdditionalField
-        exclude = []
+        fields = ["title", "field_type", "required", "help_text"]
 
 
 class EditGroupTypeForm(forms.ModelForm):
@@ -366,7 +386,7 @@ class SchoolTermForm(ExtensibleForm):
 
     class Meta:
         model = SchoolTerm
-        exclude = []
+        fields = ["name", "date_start", "date_end"]
 
 
 class DashboardWidgetOrderForm(ExtensibleForm):
diff --git a/aleksis/core/frontend/app/apollo.js b/aleksis/core/frontend/app/apollo.js
index 267997b9f93ba8d992a90093a93e650f32995811..519bc24aa5d7920d6433d4eebef1b38673753907 100644
--- a/aleksis/core/frontend/app/apollo.js
+++ b/aleksis/core/frontend/app/apollo.js
@@ -2,11 +2,12 @@
  * Configuration for Apollo provider, client, and caches.
  */
 
-import { ApolloClient, HttpLink, from } from "@/apollo-boost";
+import { ApolloClient, from } from "@/apollo-boost";
 
 import { RetryLink } from "@/apollo-link-retry";
 import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist";
 import { InMemoryCache } from "@/apollo-cache-inmemory";
+import { BatchHttpLink } from "@/apollo-link-batch-http";
 
 // Cache for GraphQL query results in memory and persistent across sessions
 const cache = new InMemoryCache();
@@ -33,14 +34,17 @@ const links = [
   // Automatically retry failed queries
   new RetryLink(),
   // Finally, the HTTP link to the real backend (Django)
-  new HttpLink({
+  new BatchHttpLink({
     uri: getGraphqlURL(),
+    batchInterval: 200,
+    batchDebounce: true,
   }),
 ];
 
 /** Upstream Apollo GraphQL client */
 const apolloClient = new ApolloClient({
   cache,
+  shouldBatch: true,
   link: from(links),
 });
 
diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue
index b6919fbe8353c305cc88141e0bbcdd095bd4231c..f650517ae88d122ea6684de14819c4a5f00cc241 100644
--- a/aleksis/core/frontend/components/app/App.vue
+++ b/aleksis/core/frontend/components/app/App.vue
@@ -25,7 +25,7 @@
           class="white--text text-decoration-none"
           @click="$router.push({ name: 'dashboard' })"
         >
-          {{ systemProperties.sitePreferences.generalTitle }}
+          {{ $root.toolbarTitle }}
         </v-toolbar-title>
 
         <v-progress-linear
@@ -246,12 +246,17 @@ export default {
     systemProperties: gqlSystemProperties,
     whoAmI: {
       query: gqlWhoAmI,
+      pollInterval: 30000,
+      result({ data }) {
+        if (data && data.whoAmI) {
+          this.$root.permissions = data.whoAmI.permissions;
+        }
+      },
       variables() {
         return {
-          permissions: this.permissionNames,
+          permissions: this.$root.permissionNames,
         };
       },
-      pollInterval: 10000,
     },
     messages: {
       query: gqlMessages,
diff --git a/aleksis/core/frontend/components/app/LanguageForm.vue b/aleksis/core/frontend/components/app/LanguageForm.vue
index 12acbe613e94a23e25f5d1195b0badf420d92aa6..cdef5932231e5b0401296b6918d009eb544588ec 100644
--- a/aleksis/core/frontend/components/app/LanguageForm.vue
+++ b/aleksis/core/frontend/components/app/LanguageForm.vue
@@ -33,17 +33,33 @@ export default {
       type: Array,
       required: true,
     },
+    defaultLanguage: {
+      type: Object,
+      required: true,
+    },
   },
   methods: {
     setLanguage: function (languageOption) {
       document.cookie = languageOption.cookie;
       this.$i18n.locale = languageOption.code;
       this.$vuetify.lang.current = languageOption.code;
+      this.language = languageOption;
     },
     nameForMenu: function (item) {
       return `${item.nameLocal} (${item.code})`;
     },
   },
+  mounted() {
+    if (
+      this.availableLanguages.filter((lang) => lang.code === this.$i18n.locale)
+        .length === 0
+    ) {
+      console.warn(
+        `Unsupported language ${this.$i18n.locale} selected, defaulting to ${this.defaultLanguage.code}`
+      );
+      this.setLanguage(this.defaultLanguage);
+    }
+  },
   name: "LanguageForm",
 };
 </script>
diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue
index 0975c580cda88d03fd8a5d34b2fb16425ece69ee..e2594aae9eb9d561a710530a3d87e345e2791c74 100644
--- a/aleksis/core/frontend/components/app/SideNav.vue
+++ b/aleksis/core/frontend/components/app/SideNav.vue
@@ -1,5 +1,10 @@
 <template>
-  <v-navigation-drawer app :value="value" @input="$emit('input', $event)">
+  <v-navigation-drawer
+    app
+    :value="value"
+    height="100dvh"
+    @input="$emit('input', $event)"
+  >
     <v-list nav dense shaped>
       <v-list-item class="logo">
         <a
@@ -10,7 +15,7 @@
           <brand-logo :site-preferences="systemProperties.sitePreferences" />
         </a>
       </v-list-item>
-      <v-list-item class="search">
+      <v-list-item v-if="checkPermission('core.search_rule')" class="search">
         <sidenav-search />
       </v-list-item>
       <v-list-item-group :value="$route.name" v-if="sideNavMenu">
@@ -82,6 +87,7 @@
         <v-spacer />
         <language-form
           :available-languages="systemProperties.availableLanguages"
+          :default-language="systemProperties.defaultLanguage"
         />
         <v-spacer />
       </div>
@@ -94,6 +100,8 @@ import BrandLogo from "./BrandLogo.vue";
 import LanguageForm from "./LanguageForm.vue";
 import SidenavSearch from "./SidenavSearch.vue";
 
+import permissionsMixin from "../../mixins/permissions.js";
+
 export default {
   name: "SideNav",
   components: {
@@ -106,6 +114,10 @@ export default {
     systemProperties: { type: Object, required: true },
     value: { type: Boolean, required: true },
   },
+  mixins: [permissionsMixin],
+  mounted() {
+    this.addPermissions(["core.search_rule"]);
+  },
 };
 </script>
 
diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql
index 99533650b65369f4a07c330399a2f452a815b763..b8ec991bda2b1b689c97f2fb339dafce9d0e1175 100644
--- a/aleksis/core/frontend/components/app/systemProperties.graphql
+++ b/aleksis/core/frontend/components/app/systemProperties.graphql
@@ -6,6 +6,12 @@
       nameLocal
       cookie
     }
+    defaultLanguage {
+      code
+      nameTranslated
+      nameLocal
+      cookie
+    }
     sitePreferences {
       themePrimary
       themeSecondary
diff --git a/aleksis/core/frontend/components/app/whoAmI.graphql b/aleksis/core/frontend/components/app/whoAmI.graphql
index 0b2877bd2cf15b5134c8e70978fb6b2218414e3a..fff7344d06817d73a37ede3f8698afaaa06e1ea6 100644
--- a/aleksis/core/frontend/components/app/whoAmI.graphql
+++ b/aleksis/core/frontend/components/app/whoAmI.graphql
@@ -1,4 +1,4 @@
-query ($permissions: [String]!) {
+query whoAmI($permissions: [String]!) {
   whoAmI {
     username
     isAuthenticated
diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue
index bbf35c8e7f3c2f9e71479c03feb86799b3328d7f..55e100ddf773e342033a7f691089c6e5d7940936 100644
--- a/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue
+++ b/aleksis/core/frontend/components/authorized_oauth_applications/AuthorizedApplications.vue
@@ -1,6 +1,5 @@
 <template>
   <div>
-    <h1 class="mb-4">{{ $t("oauth.authorized_application.title") }}</h1>
     <div v-if="$apollo.queries.accessTokens.loading">
       <v-skeleton-loader type="card"></v-skeleton-loader>
     </div>
diff --git a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
index 410e57fda0eca36d3acaca7fc0318174fd71d318..912fb779241ef577c6f900abc78a26008df008f6 100644
--- a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
+++ b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
@@ -47,7 +47,7 @@ export default {
   apollo: {
     celeryProgressByUser: {
       query: gqlCeleryProgressButton,
-      pollInterval: 1000,
+      pollInterval: 30000,
     },
   },
 };
diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue
index b64769cc6d3dacb130197a3efcea516ef3d89c83..1c51c00661816fe3e244e6d511a0e70c747dbca6 100644
--- a/aleksis/core/frontend/components/notifications/NotificationList.vue
+++ b/aleksis/core/frontend/components/notifications/NotificationList.vue
@@ -83,7 +83,7 @@ export default {
   apollo: {
     myNotifications: {
       query: gqlMyNotifications,
-      pollInterval: 1000,
+      pollInterval: 30000,
     },
   },
 };
diff --git a/aleksis/core/frontend/components/two_factor/TwoFactor.vue b/aleksis/core/frontend/components/two_factor/TwoFactor.vue
index b6c023a55ffa3d9cf467d88109b46d78cf630ab0..96df63ebd882fa413538abb4ae3f68b5445aa966 100644
--- a/aleksis/core/frontend/components/two_factor/TwoFactor.vue
+++ b/aleksis/core/frontend/components/two_factor/TwoFactor.vue
@@ -1,6 +1,5 @@
 <template>
   <div>
-    <h1 class="mb-4">{{ $t("accounts.two_factor.title") }}</h1>
     <div v-if="$apollo.queries.twoFactor.loading">
       <v-skeleton-loader type="card,card"></v-skeleton-loader>
     </div>
diff --git a/aleksis/core/frontend/index.js b/aleksis/core/frontend/index.js
index f59aa8578b59addcb420d6165c6ef1672c669c30..e54375745f0941ca8799a0537ac9fdcda6da9122 100644
--- a/aleksis/core/frontend/index.js
+++ b/aleksis/core/frontend/index.js
@@ -36,9 +36,7 @@ import routerOpts from "./app/router.js";
 import apolloOpts from "./app/apollo.js";
 
 const i18n = new VueI18n({
-  locale: Vue.$cookies.get("django_language")
-    ? Vue.$cookies.get("django_language")
-    : "en",
+  locale: Vue.$cookies.get("django_language") || navigator.language || "en",
   ...i18nOpts,
 });
 const vuetify = new Vuetify({
@@ -46,6 +44,7 @@ const vuetify = new Vuetify({
     current: Vue.$cookies.get("django_language")
       ? Vue.$cookies.get("django_language")
       : "en",
+    t: (key, ...params) => i18n.t(key, params),
   },
   ...vuetifyOpts,
 });
@@ -70,6 +69,9 @@ const app = new Vue({
     backgroundActive: true,
     invalidation: false,
     snackbarItems: [],
+    toolbarTitle: "AlekSIS®",
+    permissions: [],
+    permissionNames: [],
   }),
   computed: {
     matchedComponents() {
@@ -89,5 +91,6 @@ const app = new Vue({
 });
 
 // Late setup for some plugins handed off to out ALeksisVue plugin
+app.$loadVuetifyMessages();
 app.$loadAppMessages();
 app.$setupNavigationGuards();
diff --git a/aleksis/core/frontend/mixins/menus.js b/aleksis/core/frontend/mixins/menus.js
index 9ba58bcaa84e72917e3057614fca6058df39af3a..60ce66b5b0d36efa77cc2b8944ce21e928a1f107 100644
--- a/aleksis/core/frontend/mixins/menus.js
+++ b/aleksis/core/frontend/mixins/menus.js
@@ -1,15 +1,17 @@
 import gqlCustomMenu from "../components/app/customMenu.graphql";
 
+import permissionsMixin from "./permissions.js";
+
 /**
  * Vue mixin containing menu generation code.
  *
  * Only used by main App component, but factored out for readability.
  */
 const menusMixin = {
+  mixins: [permissionsMixin],
   data() {
     return {
       footerMenu: null,
-      permissionNames: [],
       sideNavMenu: null,
       accountMenu: null,
     };
@@ -35,8 +37,7 @@ const menusMixin = {
         }
       }
 
-      this.permissionNames = permArray;
-      this.$apollo.queries.whoAmI.refetch();
+      this.addPermissions(permArray);
     },
     buildMenu(routes, menuKey) {
       let menu = {};
@@ -99,14 +100,6 @@ const menusMixin = {
 
       return Object.values(menu);
     },
-    checkPermission(permissionName) {
-      return (
-        this.whoAmI &&
-        this.whoAmI.permissions &&
-        this.whoAmI.permissions.find((p) => p.name === permissionName) &&
-        this.whoAmI.permissions.find((p) => p.name === permissionName).result
-      );
-    },
     checkValidators(validators) {
       for (const validator of validators) {
         if (!validator(this.whoAmI)) {
@@ -118,14 +111,9 @@ const menusMixin = {
     buildMenus() {
       this.accountMenu = this.buildMenu(
         this.$router.getRoutes(),
-        "inAccountMenu",
-        this.whoAmI ? this.whoAmI.permissions : []
-      );
-      this.sideNavMenu = this.buildMenu(
-        this.$router.getRoutes(),
-        "inMenu",
-        this.whoAmI ? this.whoAmI.permissions : []
+        "inAccountMenu"
       );
+      this.sideNavMenu = this.buildMenu(this.$router.getRoutes(), "inMenu");
     },
   },
   apollo: {
diff --git a/aleksis/core/frontend/mixins/permissions.js b/aleksis/core/frontend/mixins/permissions.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d5a00f51a747e06d0d38461351d9bc63a1dc8ac
--- /dev/null
+++ b/aleksis/core/frontend/mixins/permissions.js
@@ -0,0 +1,28 @@
+/**
+ * Vue mixin containing permission checking code.
+ */
+
+const permissionsMixin = {
+  methods: {
+    checkPermission(permissionName) {
+      return (
+        this.$root.permissions &&
+        this.$root.permissions.find((p) => p.name === permissionName) &&
+        this.$root.permissions.find((p) => p.name === permissionName).result
+      );
+    },
+    addPermissions(newPermissionNames) {
+      const keepPermissionNames = this.$root.permissionNames.filter(
+        (oldPermName) =>
+          !newPermissionNames.find((newPermName) => newPermName === oldPermName)
+      );
+
+      this.$root.permissionNames = [
+        ...keepPermissionNames,
+        ...newPermissionNames,
+      ];
+    },
+  },
+};
+
+export default permissionsMixin;
diff --git a/aleksis/core/frontend/mixins/routes.js b/aleksis/core/frontend/mixins/routes.js
index 9adbdafc7426fcc19174f91266b978471f3a38e9..e458b23a6f65d22f9682178980ab8e15fe1e74d5 100644
--- a/aleksis/core/frontend/mixins/routes.js
+++ b/aleksis/core/frontend/mixins/routes.js
@@ -14,7 +14,7 @@ const routesMixin = {
   apollo: {
     dynamicRoutes: {
       query: gqlDynamicRoutes,
-      pollInterval: 10000,
+      pollInterval: 30000,
     },
   },
   watch: {
diff --git a/aleksis/core/frontend/plugins/aleksis.js b/aleksis/core/frontend/plugins/aleksis.js
index c35b9127f78f6c26c3e5c052cc5732322f509090..c129aac331c51c1135203ed633a6fd3fccbc942f 100644
--- a/aleksis/core/frontend/plugins/aleksis.js
+++ b/aleksis/core/frontend/plugins/aleksis.js
@@ -5,6 +5,7 @@
 // aleksisAppImporter is a virtual module defined in Vite config
 import { appMessages } from "aleksisAppImporter";
 import aleksisMixin from "../mixins/aleksis.js";
+import * as langs from "@/vuetify/src/locale";
 
 console.debug("Defining AleksisVue plugin");
 const AleksisVue = {};
@@ -104,6 +105,33 @@ AleksisVue.install = function (Vue) {
     document.title = newTitle;
   };
 
+  /**
+   * Set the toolbar title visible on the page.
+   *
+   * This will automatically add the base title discovered at app loading time.
+   *
+   * @param {string} title Specific title to set, or null.
+   * @param {Object} route Route to discover title from, or null.
+   */
+  Vue.prototype.$setToolBarTitle = function (title, route) {
+    let newTitle;
+
+    if (title) {
+      newTitle = title;
+    } else {
+      if (!route) {
+        route = this.$route;
+      }
+      if (route.meta.toolbarTitle) {
+        newTitle = this.$t(route.meta.toolbarTitle);
+      }
+    }
+
+    newTitle = newTitle || Vue.$pageBaseTitle;
+    console.debug(`Setting toolbar title: ${newTitle}`);
+    this.$root.toolbarTitle = newTitle;
+  };
+
   /**
    * Load i18n messages from all known AlekSIS apps.
    */
@@ -115,6 +143,15 @@ AleksisVue.install = function (Vue) {
     }
   };
 
+  /**
+   * Load vuetifys built-in translations
+   */
+  Vue.prototype.$loadVuetifyMessages = function () {
+    for (const [locale, messages] of Object.entries(langs)) {
+      this.$i18n.mergeLocaleMessage(locale, { $vuetify: messages });
+    }
+  };
+
   /**
    * Invalidate state and force reload from server.
    *
@@ -150,6 +187,7 @@ AleksisVue.install = function (Vue) {
     this.$router.afterEach((to, from, next) => {
       console.debug("Setting new page title due to route change");
       vm.$setPageTitle(null, to);
+      vm.$setToolBarTitle(null, to);
     });
 
     // eslint-disable-next-line no-unused-vars
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index aa84f36c4aa9f634f081edb9338f8f264dc9c2fe..2af742809548f5f2f70e9c13a1b7091e6d8145c5 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -54,6 +54,7 @@ const routes = [
       icon: "mdi-key-outline",
       titleKey: "accounts.invitation.accept_invitation.menu_title",
       validators: [notLoggedInValidator],
+      permission: "core.invite_enabled",
     },
   },
   {
@@ -734,6 +735,7 @@ const routes = [
     meta: {
       inAccountMenu: true,
       titleKey: "accounts.two_factor.menu_title",
+      toolbarTitle: "accounts.two_factor.title",
       icon: "mdi-two-factor-authentication",
       permission: "core.manage_2fa_rule",
     },
@@ -928,6 +930,7 @@ const routes = [
     meta: {
       inAccountMenu: true,
       titleKey: "oauth.authorized_application.menu_title",
+      toolbarTitle: "oauth.authorized_application.title",
       icon: "mdi-gesture-tap-hold",
       permission: "core.manage_authorized_tokens_rule",
     },
@@ -956,14 +959,6 @@ const routes = [
       invalidate: "leave",
     },
   },
-  {
-    path: "/invitations/code/enter",
-    component: () => import("./components/LegacyBaseTemplate.vue"),
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
-    name: "core.enter_invitation_code",
-  },
   {
     path: "/invitations/code/generate",
     component: () => import("./components/LegacyBaseTemplate.vue"),
diff --git a/aleksis/core/schema/system_properties.py b/aleksis/core/schema/system_properties.py
index 1546512fc4e488faa01a93d87510249577c63388..6d6e50d5958601db24eca6e61d959482611cf5af 100644
--- a/aleksis/core/schema/system_properties.py
+++ b/aleksis/core/schema/system_properties.py
@@ -20,6 +20,7 @@ class LanguageType(graphene.ObjectType):
 
 class SystemPropertiesType(graphene.ObjectType):
     current_language = graphene.String(required=True)
+    default_language = graphene.Field(LanguageType)
     available_languages = graphene.List(LanguageType)
     site_preferences = graphene.Field(SitePreferencesType)
     custom_menu_by_name = graphene.Field(CustomMenuType)
@@ -27,6 +28,11 @@ class SystemPropertiesType(graphene.ObjectType):
     def resolve_current_language(parent, info, **kwargs):
         return info.context.LANGUAGE_CODE
 
+    @staticmethod
+    def resolve_default_language(root, info, **kwargs):
+        code = settings.LANGUAGE_CODE
+        return translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
+
     def resolve_available_languages(parent, info, **kwargs):
         return [
             translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index fbe25a785c289e415445b6cf52bc9ff43bfeb477..656fcd30a69e245c02d7bf2a984517265c512da9 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -576,7 +576,7 @@ YARN_INSTALLED_APPS = [
     "@fontsource/roboto@^4.5.5",
     "jquery@^3.6.0",
     "@materializecss/materialize@~1.0.0",
-    "material-design-icons-iconfont@^6.6.0",
+    "material-design-icons-iconfont@^6.7.0",
     "select2-materialize@^0.1.8",
     "paper-css@^0.4.1",
     "jquery-sortablejs@^1.0.1",
@@ -585,8 +585,9 @@ YARN_INSTALLED_APPS = [
     "luxon@^2.3.2",
     "@iconify/iconify@^2.2.1",
     "@iconify/json@^2.1.30",
-    "@mdi/font@^6.9.96",
+    "@mdi/font@^7.2.96",
     "apollo-boost@^0.4.9",
+    "apollo-link-batch-http@^1.2.14",
     "apollo-link-retry@^2.2.16",
     "apollo3-cache-persist@^0.14.1",
     "deepmerge@^4.2.2",
diff --git a/aleksis/core/templates/account/email/email_confirmation_message.txt b/aleksis/core/templates/account/email/email_confirmation_message.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f795c60f7b66c75b37217caed8c38c3d7f9295e0
--- /dev/null
+++ b/aleksis/core/templates/account/email/email_confirmation_message.txt
@@ -0,0 +1,7 @@
+{% extends "account/email/base_message.txt" %}
+{% load account %}
+{% load html_helpers %}
+{% load i18n %}
+
+{% block content %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Someone tried to register an account with the username {{ user_display }} and your e-mail address on {{ site_domain }}.
+If it was you, please confirm the registration by clicking on the following link:{% endblocktrans %} {{ activate_url|remove_prefix:"/django/" }}{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 9792fcb75cd1e7197cda1f1e6707c3eb42ed1d3e..7a22c7bb20a9d3c2d0d0e5af6bece161ac5dbdfa 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -27,7 +27,7 @@ urlpatterns = [
     path("__icons__/", include("dj_iconify.urls")),
     path(
         "graphql/",
-        csrf_exempt(views.LoggingGraphQLView.as_view(graphiql=True)),
+        csrf_exempt(views.LoggingGraphQLView.as_view(batch=True)),
         name="graphql",
     ),
     path("logo", force_maintenance_mode_off(views.LogoView.as_view()), name="logo"),
@@ -42,11 +42,11 @@ urlpatterns = [
     ),
     path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
     path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"),
+    path("", include("django_prometheus.urls")),
     path(
         "django/",
         include(
             [
-                path("", include("django_prometheus.urls")),
                 path("account/login/", views.LoginView.as_view(), name="login"),
                 path(
                     "accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"
diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst
index 1b82cf5e35c78585ee01b101120ceaff8fa19305..f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf 100644
--- a/docs/dev/01_setup.rst
+++ b/docs/dev/01_setup.rst
@@ -90,7 +90,7 @@ All three steps can be done with the ``poetry shell`` command and
 ``aleksis-admin``::
 
   ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell
-   poetry run aleksis-admin yarn install
+   poetry run aleksis-admin vite build
    poetry run aleksis-admin collectstatic
    poetry run aleksis-admin compilemessages
    poetry run aleksis-admin migrate
diff --git a/pyproject.toml b/pyproject.toml
index f5f4f37bd1b7e4dfb02607cb9f3307d5cc942ef6..3445fb1e0e322123b5c006e0086d7e48485be76c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,13 +106,13 @@ django-model-utils = "^4.0.0"
 bs4 = "^0.0.1"
 django-invitations = "^2.0.0"
 django-cleavejs = "^0.1.0"
-django-allauth = "^0.53.0"
+django-allauth = "^0.54.0"
 django-uwsgi-ng = "^2.0"
 django-extensions = "^3.1.1"
 ipython = "^8.0.0"
 django-oauth-toolkit = "^2.0.0"
-django-storages = {version = "^1.11.1", optional = true}
-boto3 = {version = "^1.17.33", optional = true}
+django-storages = {version = "^1.13.2", optional = true}
+boto3 = {version = "^1.26.142", optional = true}
 django-cleanup = "^7.0.0"
 djangorestframework = "^3.12.4"
 Whoosh = "^2.7.4"