diff --git a/aleksis/core/assets/App.vue b/aleksis/core/assets/App.vue
index 569d0ee52cca41015e1882e08d5a744ed6727e08..10a8b1714aa5498e40cd46fd5314a47520907ecb 100644
--- a/aleksis/core/assets/App.vue
+++ b/aleksis/core/assets/App.vue
@@ -218,7 +218,9 @@
           <message-box
             type="error"
             v-else-if="
-              permissionResults && !checkPermission($route.meta.permission)
+              whoAmI &&
+              !$apollo.queries.whoAmI.loading &&
+              !checkPermission($route.meta.permission)
             "
           >
             {{ $t("base.no_permission") }}
@@ -341,13 +343,10 @@ import gqlWhoAmI from "./whoAmI.graphql";
 import gqlMessages from "./messages.graphql";
 import gqlSystemProperties from "./systemProperties.graphql";
 import gqlCustomMenu from "./customMenu.graphql";
-import gqlGlobalPermissions from "./globalPermissions.graphql";
 import gqlSnackbarItems from "./snackbarItems.graphql";
 
 import useRegisterSWMixin from "./mixins/useRegisterSW";
 
-// import { VOffline } from "@/v-offline";
-
 export default {
   data() {
     return {
@@ -356,7 +355,6 @@ export default {
       systemProperties: null,
       messages: null,
       footerMenu: null,
-      permissionResults: null,
       permissionNames: [],
       sideNavMenu: null,
       accountMenu: null,
@@ -377,7 +375,8 @@ export default {
         }
       }
 
-      this.$data.permissionNames = permArray;
+      this.permissionNames = permArray;
+      this.$apollo.queries.whoAmI.refetch();
     },
     buildMenu(routes, menuKey) {
       let menu = {};
@@ -391,6 +390,9 @@ export default {
           !route.parent &&
           (route.meta.permission
             ? this.checkPermission(route.meta.permission)
+            : true) &&
+          (route.meta.validators
+            ? this.checkValidators(route.meta.validators)
             : true)
         ) {
           let menuItem = {
@@ -414,6 +416,9 @@ export default {
           route.parent.name in menu &&
           (route.meta.permission
             ? this.checkPermission(route.meta.permission)
+            : true) &&
+          (route.meta.validators
+            ? this.checkValidators(route.meta.validators)
             : true)
         ) {
           let menuItem = {
@@ -430,9 +435,30 @@ export default {
     },
     checkPermission(permissionName) {
       return (
-        this.permissionResults &&
-        this.permissionResults.find((p) => p.name === permissionName) &&
-        this.permissionResults.find((p) => p.name === permissionName).result
+        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)) {
+          return false;
+        }
+      }
+      return true;
+    },
+    buildMenus() {
+      this.accountMenu = this.buildMenu(
+        this.$router.getRoutes(),
+        "inAccountMenu",
+        this.whoAmI.permissions
+      );
+      this.sideNavMenu = this.buildMenu(
+        this.$router.getRoutes(),
+        "inMenu",
+        this.whoAmI.permissions
       );
     },
   },
@@ -440,6 +466,11 @@ export default {
     systemProperties: gqlSystemProperties,
     whoAmI: {
       query: gqlWhoAmI,
+      variables() {
+        return {
+          permissions: this.permissionNames,
+        };
+      },
       pollInterval: 10000,
     },
     snackbarItems: {
@@ -459,15 +490,6 @@ export default {
       },
       update: (data) => data.customMenuByName,
     },
-    permissionResults: {
-      query: gqlGlobalPermissions,
-      variables() {
-        return {
-          permissions: this.$data.permissionNames,
-        };
-      },
-      update: (data) => data.globalPermissionsByName,
-    },
   },
   watch: {
     systemProperties: function (newProperties) {
@@ -482,23 +504,12 @@ export default {
       this.$vuetify.theme.themes.dark.secondary =
         newProperties.sitePreferences.themeSecondary;
     },
-    whoAmI: function (user) {
-      this.$vuetify.theme.dark =
-        user.person && user.person.preferences.themeDesignMode === "dark";
-      this.$apollo.queries.permissionResults.refetch();
-    },
-    permissionResults: {
-      handler(newResults) {
-        this.$data.accountMenu = this.buildMenu(
-          this.$router.getRoutes(),
-          "inAccountMenu",
-          newResults
-        );
-        this.$data.sideNavMenu = this.buildMenu(
-          this.$router.getRoutes(),
-          "inMenu",
-          newResults
-        );
+    whoAmI: {
+      handler() {
+        this.$vuetify.theme.dark =
+          this.whoAmI.person &&
+          this.whoAmI.person.preferences.themeDesignMode === "dark";
+        this.buildMenus();
       },
       deep: true,
     },
@@ -516,7 +527,6 @@ export default {
     CeleryProgressBottom,
     Loading,
     SnackbarItem,
-    // VOffline,
   },
   mixins: [useRegisterSWMixin],
 };
diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js
index 01c0b672dcd41a9c539c4f1894e6bbf08cc8c156..3d9a7f06b028dc3d5ade00f2e83a8cfc63a242ca 100644
--- a/aleksis/core/assets/app.js
+++ b/aleksis/core/assets/app.js
@@ -1,5 +1,6 @@
 import Vue from "vue";
 import VueRouter from "@/vue-router";
+import AleksisVue from "./plugins/aleksis.js";
 
 import Vuetify from "@/vuetify";
 import "@/@mdi/font/css/materialdesignicons.css";
@@ -62,6 +63,7 @@ i18n.registerLocale = function (messages) {
 
 Vue.use(VueApollo);
 Vue.use(VueRouter);
+Vue.use(AleksisVue);
 
 export const typeDefs = gql`
   type snackbarItem {
@@ -164,6 +166,9 @@ const router = new VueRouter({
   routes,
 });
 
+router.afterEach((to, from, next) => {
+  app.$setPageTitle(null, to);
+});
 if (document.getElementById("sentry_settings") !== null) {
   const Sentry = import("@sentry/vue");
   const { BrowserTracing } = import("@sentry/tracing");
@@ -193,6 +198,7 @@ const app = new Vue({
   data: () => ({
     showCacheAlert: false,
     contentLoading: false,
+    pageBaseTitle: document.title,
   }),
   router,
   i18n,
@@ -211,6 +217,13 @@ router.afterEach((to, from) => {
   });
 });
 
+// eslint-disable-next-line no-unused-vars
+router.beforeEach((to, from, next) => {
+  console.debug("Setting new page title due to route change");
+  app.$setPageTitle(null, to);
+  next();
+});
+
 window.app = app;
 window.router = router;
 window.i18n = i18n;
diff --git a/aleksis/core/assets/components/BrandLogo.vue b/aleksis/core/assets/components/BrandLogo.vue
index fa7fce53bb190360615599b5ba0604d9535ef721..8d32dd0c22382b1047df9150a8e3d0c247197a4f 100644
--- a/aleksis/core/assets/components/BrandLogo.vue
+++ b/aleksis/core/assets/components/BrandLogo.vue
@@ -1,7 +1,7 @@
 <template>
   <img
     :src="sitePreferences.themeLogo.url"
-    :alt="sitePreferences.generalTitle + ' – Logo'"
+    :alt="sitePreferences.generalTitle + ' – ' + $t('base.logo')"
     class="fullsize"
   />
 </template>
diff --git a/aleksis/core/assets/components/Error404.vue b/aleksis/core/assets/components/Error404.vue
index 79160f5d4a8abb5bc359c47e11907339bfa7eeee..017f53174179ce346ab40956a8c05d2ff1ea17b4 100644
--- a/aleksis/core/assets/components/Error404.vue
+++ b/aleksis/core/assets/components/Error404.vue
@@ -5,9 +5,9 @@
   >
     <h1 class="text-h2">{{ $t("network_errors.error_404") }}</h1>
     <div>{{ $t("network_errors.page_not_found") }}</div>
-    <v-btn color="secondary" :to="{ name: 'dashboard' }">{{
-      $t("network_errors.take_me_back")
-    }}</v-btn>
+    <v-btn color="secondary" :to="{ name: 'dashboard' }">
+      {{ $t("network_errors.take_me_back") }}
+    </v-btn>
   </div>
 </template>
 
diff --git a/aleksis/core/assets/components/LegacyBaseTemplate.vue b/aleksis/core/assets/components/LegacyBaseTemplate.vue
index be51d148b37aa138a8d709811f384a5cd5e44e8e..a295a24ffe104a5368f7e1a1bdae9f3108010134 100644
--- a/aleksis/core/assets/components/LegacyBaseTemplate.vue
+++ b/aleksis/core/assets/components/LegacyBaseTemplate.vue
@@ -53,7 +53,7 @@ export default {
         this.$root.contentLoading = true;
       };
       const title = this.$refs.contentIFrame.contentWindow.document.title;
-      document.title = title;
+      this.$root.$setPageTitle(title);
     },
   },
   watch: {
diff --git a/aleksis/core/assets/components/about/AboutAleksis.vue b/aleksis/core/assets/components/about/AboutAleksis.vue
index 2f39581d430a06b57bec0a9367983023ddbb9f95..74ba7a0152dba8059039c3681868fa3e69dde239 100644
--- a/aleksis/core/assets/components/about/AboutAleksis.vue
+++ b/aleksis/core/assets/components/about/AboutAleksis.vue
@@ -13,12 +13,12 @@
         </v-card-text>
         <v-spacer></v-spacer>
         <v-card-actions>
-          <v-btn text color="primary" href="https://aleksis.org/">{{
-            $t("about.website_of_aleksis")
-          }}</v-btn>
-          <v-btn text color="primary" href="https://edugit.org/AlekSIS/">{{
-            $t("about.source_code")
-          }}</v-btn>
+          <v-btn text color="primary" href="https://aleksis.org/">
+            {{ $t("about.website_of_aleksis") }}
+          </v-btn>
+          <v-btn text color="primary" href="https://edugit.org/AlekSIS/">
+            {{ $t("about.source_code") }}
+          </v-btn>
         </v-card-actions>
       </v-card>
     </v-col>
@@ -30,19 +30,19 @@
             {{ $t("about.licence_information_1") }}
           </p>
           <p>
-            <v-chip color="green" text-color="white" small>{{
-              $t("about.free_open_source_licence")
-            }}</v-chip>
-            <v-chip color="orange" text-color="white" small>{{
-              $t("about.other_licence")
-            }}</v-chip>
+            <v-chip color="green" text-color="white" small>
+              {{ $t("about.free_open_source_licence") }}
+            </v-chip>
+            <v-chip color="orange" text-color="white" small>
+              {{ $t("about.other_licence") }}
+            </v-chip>
           </p>
         </v-card-text>
         <v-spacer></v-spacer>
         <v-card-actions>
-          <v-btn text color="primary" href="https://eupl.eu">{{
-            $t("about.full_licence_text")
-          }}</v-btn>
+          <v-btn text color="primary" href="https://eupl.eu">
+            {{ $t("about.full_licence_text") }}
+          </v-btn>
           <v-btn
             text
             color="primary"
diff --git a/aleksis/core/assets/components/about/InstalledAppCard.vue b/aleksis/core/assets/components/about/InstalledAppCard.vue
index 796808abd127324caf48d71e5e79afcc24da8380..99f63dfd0af39e33a8da7f4e3289883baa16a580 100644
--- a/aleksis/core/assets/components/about/InstalledAppCard.vue
+++ b/aleksis/core/assets/components/about/InstalledAppCard.vue
@@ -13,9 +13,9 @@
         <v-row v-if="app.licence" class="mb-2">
           <v-col cols="6">
             {{ $t("about.licenced_under") }} <br />
-            <strong class="text-body-1 black--text">{{
-              app.licence.verboseName
-            }}</strong>
+            <strong class="text-body-1 black--text">
+              {{ app.licence.verboseName }}
+            </strong>
           </v-col>
           <v-col cols="6">
             {{ $t("about.licence_type") }} <br />
@@ -75,7 +75,7 @@
 
       <v-card-actions v-if="app.urls.length !== 0">
         <v-btn text color="primary" @click="reveal = true">
-          Show copyright
+          {{ $t("about.show_copyright") }}
         </v-btn>
         <v-btn
           v-for="url in app.urls"
@@ -98,9 +98,9 @@
               <v-col cols="12" v-if="app.copyrights.length !== 0">
                 <span v-for="(copyright, index) in app.copyrights" :key="index">
                   Copyright © {{ copyright.years }}
-                  <a :href="'mailto:' + copyright.email">{{
-                    copyright.name
-                  }}</a>
+                  <a :href="'mailto:' + copyright.email">
+                    {{ copyright.name }}
+                  </a>
                   <br />
                 </span>
               </v-col>
@@ -108,7 +108,9 @@
           </v-card-text>
           <v-spacer></v-spacer>
           <v-card-actions class="pt-0">
-            <v-btn text color="primary" @click="reveal = false"> Close </v-btn>
+            <v-btn text color="primary" @click="reveal = false">{{
+              $t("actions.close")
+            }}</v-btn>
           </v-card-actions>
         </v-card>
       </v-expand-transition>
diff --git a/aleksis/core/assets/components/generic/ListView.vue b/aleksis/core/assets/components/generic/ListView.vue
index 7a78a7643b2f08846f341677eb82074eda31013e..bc68b2dbf9f5a5dea29a792cab497ffe74931b3e 100644
--- a/aleksis/core/assets/components/generic/ListView.vue
+++ b/aleksis/core/assets/components/generic/ListView.vue
@@ -20,9 +20,7 @@ export default {
   components: {
     DetailView,
   },
-}
+};
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>
diff --git a/aleksis/core/assets/components/person/AdditionalImage.vue b/aleksis/core/assets/components/person/AdditionalImage.vue
index 5ea3479bb42ebcac6211874c022454c7677b8968..f483705806bb5eb718ccf136a4773b4a5bac7130 100644
--- a/aleksis/core/assets/components/person/AdditionalImage.vue
+++ b/aleksis/core/assets/components/person/AdditionalImage.vue
@@ -27,7 +27,7 @@
     <v-list>
       <v-list-item>
         <v-list-item-icon>
-          <v-icon> mdi-image-off-outline </v-icon>
+          <v-icon>mdi-image-off-outline</v-icon>
         </v-list-item-icon>
 
         <v-list-item-content>
diff --git a/aleksis/core/assets/components/person/PersonActions.vue b/aleksis/core/assets/components/person/PersonActions.vue
index 2b0bc7bc6e55d985b6c66c3d7b9dbc92b13344b4..9e9518c9a67e6bc95ee2bd09258e6f80798c76f5 100644
--- a/aleksis/core/assets/components/person/PersonActions.vue
+++ b/aleksis/core/assets/components/person/PersonActions.vue
@@ -1,36 +1,36 @@
 <template>
   <ApolloQuery :query="require('./personActions.graphql')" :variables="{ id }">
     <template #default="{ result: { error, data, loading } }">
-      <v-skeleton-loader v-if="loading" type="actions"/>
+      <v-skeleton-loader v-if="loading" type="actions" />
       <template v-else-if="data && data.person && data.person.id">
         <v-btn
-            v-if="data.person.canEditPerson"
-            color="primary"
-            :to="{ name: 'core.editPerson', params: { id: data.person.id } }"
+          v-if="data.person.canEditPerson"
+          color="primary"
+          :to="{ name: 'core.editPerson', params: { id: data.person.id } }"
         >
           <v-icon left>$edit</v-icon>
           {{ $t("actions.edit") }}
         </v-btn>
         <v-btn
-            v-if="data.person.canChangePersonPreferences"
-            color="secondary"
-            outlined
-            text
-            :to="{
-              name: 'core.preferencesPersonByPk',
-              params: { pk: data.person.id },
-            }"
+          v-if="data.person.canChangePersonPreferences"
+          color="secondary"
+          outlined
+          text
+          :to="{
+            name: 'core.preferencesPersonByPk',
+            params: { pk: data.person.id },
+          }"
         >
           <v-icon left>$preferences</v-icon>
           {{ $t("preferences.person.change_preferences") }}
         </v-btn>
 
         <v-menu
-            v-if="
-              data.person.canImpersonatePerson ||
-              data.person.canInvitePerson ||
-              data.person.canDeletePerson
-            "
+          v-if="
+            data.person.canImpersonatePerson ||
+            data.person.canInvitePerson ||
+            data.person.canDeletePerson
+          "
         >
           <template #activator="{ on, attrs }">
             <v-btn outlined text v-bind="attrs" v-on="on">
@@ -39,57 +39,54 @@
           </template>
           <v-list>
             <v-list-item
-                v-if="data.person.canImpersonatePerson"
-                :to="{
-                  name: 'impersonate.impersonateByUserPk',
-                  params: { uid: data.person.userid },
-                  query: { next: $route.path },
-                }"
+              v-if="data.person.canImpersonatePerson"
+              :to="{
+                name: 'impersonate.impersonateByUserPk',
+                params: { uid: data.person.userid },
+                query: { next: $route.path },
+              }"
             >
               <v-list-item-icon>
                 <v-icon>mdi-account-box-outline</v-icon>
               </v-list-item-icon>
               <v-list-item-content>
-                <v-list-item-title>{{
-                    $t("person.impersonation.impersonate")
-                  }}
+                <v-list-item-title>
+                  {{ $t("person.impersonation.impersonate") }}
                 </v-list-item-title>
               </v-list-item-content>
             </v-list-item>
 
             <v-list-item
-                v-if="data.person.canInvitePerson"
-                :to="{
-                  name: 'core.invitePerson',
-                  params: { id: data.person.id },
-                }"
+              v-if="data.person.canInvitePerson"
+              :to="{
+                name: 'core.invitePerson',
+                params: { id: data.person.id },
+              }"
             >
               <v-list-item-icon>
                 <v-icon>mdi-account-plus-outline</v-icon>
               </v-list-item-icon>
               <v-list-item-content>
-                <v-list-item-title>{{
-                    $t("person.invite")
-                  }}
+                <v-list-item-title>
+                  {{ $t("person.invite") }}
                 </v-list-item-title>
               </v-list-item-content>
             </v-list-item>
 
             <v-list-item
-                v-if="data.person.canDeletePerson"
-                :to="{
-                  name: 'core.deletePerson',
-                  params: { id: data.person.id },
-                }"
-                class="error--text"
+              v-if="data.person.canDeletePerson"
+              :to="{
+                name: 'core.deletePerson',
+                params: { id: data.person.id },
+              }"
+              class="error--text"
             >
               <v-list-item-icon>
                 <v-icon color="error">mdi-delete</v-icon>
               </v-list-item-icon>
               <v-list-item-content>
-                <v-list-item-title>{{
-                    $t("person.delete")
-                  }}
+                <v-list-item-title>
+                  {{ $t("person.delete") }}
                 </v-list-item-title>
               </v-list-item-content>
             </v-list-item>
@@ -112,6 +109,4 @@ export default {
 };
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>
diff --git a/aleksis/core/assets/components/person/PersonOverview.vue b/aleksis/core/assets/components/person/PersonOverview.vue
index 4aabae0faa0914b800634e714c1f8e69c807daf7..be36914c7b316e0ef415ec192eadfb82207ed3a7 100644
--- a/aleksis/core/assets/components/person/PersonOverview.vue
+++ b/aleksis/core/assets/components/person/PersonOverview.vue
@@ -82,38 +82,54 @@
                   </v-list-item>
                   <v-divider inset />
 
-                  <v-list-item :href="'tel:' + data.person.phoneNumber">
+                  <v-list-item
+                    :href="
+                      data.person.phoneNumber
+                        ? 'tel:' + data.person.phoneNumber
+                        : ''
+                    "
+                  >
                     <v-list-item-icon>
                       <v-icon> mdi-phone-outline</v-icon>
                     </v-list-item-icon>
 
                     <v-list-item-content>
-                      <v-list-item-title
-                        >{{ data.person.phoneNumber || "–" }}
+                      <v-list-item-title>
+                        {{ data.person.phoneNumber || "–" }}
                       </v-list-item-title>
-                      <v-list-item-subtitle
-                        >{{ $t("person.home") }}
+                      <v-list-item-subtitle>
+                        {{ $t("person.home") }}
                       </v-list-item-subtitle>
                     </v-list-item-content>
                   </v-list-item>
 
-                  <v-list-item :href="'tel:' + data.person.mobileNumber">
+                  <v-list-item
+                    :href="
+                      data.person.mobileNumber
+                        ? 'tel:' + data.person.mobileNumber
+                        : ''
+                    "
+                  >
                     <v-list-item-action></v-list-item-action>
 
                     <v-list-item-content>
-                      <v-list-item-title
-                        >{{ data.person.mobileNumber || "–" }}
+                      <v-list-item-title>
+                        {{ data.person.mobileNumber || "–" }}
                       </v-list-item-title>
-                      <v-list-item-subtitle
-                        >{{ $t("person.mobile") }}
+                      <v-list-item-subtitle>
+                        {{ $t("person.mobile") }}
                       </v-list-item-subtitle>
                     </v-list-item-content>
                   </v-list-item>
                   <v-divider inset />
 
-                  <v-list-item :href="'mailto:' + data.person.email">
+                  <v-list-item
+                    :href="
+                      data.person.email ? 'mailto:' + data.person.email : ''
+                    "
+                  >
                     <v-list-item-icon>
-                      <v-icon> mdi-email-outline</v-icon>
+                      <v-icon>mdi-email-outline</v-icon>
                     </v-list-item-icon>
 
                     <v-list-item-content>
diff --git a/aleksis/core/assets/globalPermissions.graphql b/aleksis/core/assets/globalPermissions.graphql
deleted file mode 100644
index 0d521e45e26ac6f22bba64d08a1f04b57e59dff2..0000000000000000000000000000000000000000
--- a/aleksis/core/assets/globalPermissions.graphql
+++ /dev/null
@@ -1,6 +0,0 @@
-query ($permissions: [String]!) {
-  globalPermissionsByName(permissions: $permissions) {
-    name
-    result
-  }
-}
diff --git a/aleksis/core/assets/messages/en.json b/aleksis/core/assets/messages/en.json
index b2679b64a49563ff4a9491cfe891ef4d6ecfe6f5..d4a5e52784afd429cced438b0380faf5a421104a 100644
--- a/aleksis/core/assets/messages/en.json
+++ b/aleksis/core/assets/messages/en.json
@@ -16,7 +16,8 @@
     "other_licence": "Other Licence",
     "proprietary": "Proprietary",
     "source_code": "Source Code",
-    "website_of_aleksis": "Website of AlekSIS"
+    "website_of_aleksis": "Website of AlekSIS",
+    "show_copyright": "Show copyright"
   },
   "accounts": {
     "change_password": {
@@ -49,7 +50,8 @@
   "actions": {
     "back": "Back",
     "search": "Search",
-    "edit": "Edit"
+    "edit": "Edit",
+    "close": "Close"
   },
   "administration": {
     "backend_admin": {
@@ -74,7 +76,8 @@
     "person_is_dummy": "Your administrator account is not linked to any person. Therefore, a dummy person has been linked to your account.",
     "privacy_policy": "Privacy Policy",
     "user_not_linked_to_person": "Your user account is not linked to a person. This means you cannot access any school-related information. Please contact the managers of AlekSIS at your school.",
-    "no_permission": "You have no permission to view this page. Please login with an other account."
+    "no_permission": "You have no permission to view this page. Please login with an other account.",
+    "logo": "Logo"
   },
   "celery_progress": {
     "error_message": "The operation couldn't be finished successfully.",
diff --git a/aleksis/core/assets/plugins/aleksis.js b/aleksis/core/assets/plugins/aleksis.js
new file mode 100644
index 0000000000000000000000000000000000000000..87ebfcb680576db41acad426c6c1b3055e213fc8
--- /dev/null
+++ b/aleksis/core/assets/plugins/aleksis.js
@@ -0,0 +1,28 @@
+const AleksisVue = {};
+
+console.debug("Defining AleksisVue plugin");
+
+AleksisVue.install = function (Vue, options) {
+  Vue.prototype.$setPageTitle = function (title, route) {
+    let titleParts = [];
+
+
+    if (title) {
+      titleParts.push(title);
+    } else {
+      if (!route) {
+        route = this.$route;
+      };
+      if (route.meta.titleKey) {
+        titleParts.push(this.$t(route.meta.titleKey));
+      }
+    }
+
+    titleParts.push(this.pageBaseTitle);
+    const newTitle = titleParts.join(" – ");
+    console.debug(`Setting page title: ${newTitle}`);
+    document.title = newTitle;
+  };
+};
+
+export default AleksisVue;
diff --git a/aleksis/core/assets/routeValidators.js b/aleksis/core/assets/routeValidators.js
new file mode 100644
index 0000000000000000000000000000000000000000..5226209a5419f87f79308e174bf329c3a32ca642
--- /dev/null
+++ b/aleksis/core/assets/routeValidators.js
@@ -0,0 +1,5 @@
+const notLoggedInValidator = (whoAmI) => {
+  return !whoAmI;
+};
+
+export { notLoggedInValidator };
diff --git a/aleksis/core/assets/routes.js b/aleksis/core/assets/routes.js
index 24dcb93d146afaedd1b27a74f4750339b1ac7d93..a69cb07c4ebb1b83c99f045cc43b7deeeb850fd9 100644
--- a/aleksis/core/assets/routes.js
+++ b/aleksis/core/assets/routes.js
@@ -4,6 +4,8 @@
 //  and generates importing code at bundle time.
 import apps from "aleksisAppImporter";
 
+import { notLoggedInValidator } from "./routeValidators";
+
 const routes = [
   {
     path: "/account/login/",
@@ -14,6 +16,7 @@ const routes = [
       icon: "mdi-login-variant",
       titleKey: "accounts.login.menu_title",
       permission: "core.login_rule",
+      validators: [notLoggedInValidator],
     },
   },
   {
@@ -25,6 +28,7 @@ const routes = [
       icon: "mdi-account-plus-outline",
       titleKey: "accounts.signup.menu_title",
       permission: "core.signup_rule",
+      validators: [notLoggedInValidator],
     },
   },
   {
@@ -36,6 +40,7 @@ const routes = [
       icon: "mdi-key-outline",
       titleKey: "accounts.invitation.accept_invitation.menu_title",
       permission: "core.accept_invite_rule",
+      validators: [notLoggedInValidator],
     },
   },
   {
@@ -81,6 +86,9 @@ const routes = [
         component: () => import("./components/person/PersonOverview.vue"),
         name: "core.personById",
         props: true,
+        meta: {
+          titleKey: "person.pageTitle",
+        },
       },
       {
         path: "/persons/:id(\\d+)/edit/",
@@ -752,6 +760,9 @@ const routes = [
     path: "/about",
     component: () => import("./components/about/About.vue"),
     name: "core.about",
+    meta: {
+      titleKey: "about.pageTitle",
+    },
   },
 ];
 
diff --git a/aleksis/core/assets/whoAmI.graphql b/aleksis/core/assets/whoAmI.graphql
index 0137faa7b414bf338dea39dcfcc220c60af8326c..7223ea0c8c618f091bb4036764d962abf335d62c 100644
--- a/aleksis/core/assets/whoAmI.graphql
+++ b/aleksis/core/assets/whoAmI.graphql
@@ -1,4 +1,4 @@
-{
+query ($permissions: [String]!) {
   whoAmI {
     username
     isAuthenticated
@@ -15,5 +15,9 @@
         themeDesignMode
       }
     }
+    permissions: globalPermissionsByName(permissions: $permissions) {
+      name
+      result
+    }
   }
 }
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index d043bed2296da612408b4c99b5e21e08f8ccec3e..ba4021e9475e534a4acf208a965618ccfd310f5f 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -353,10 +353,10 @@ rules.add_perm("core.reset_password_rule", reset_password_predicate)
 invite_enabled_predicate = is_site_preference_set(section="auth", pref="invite_enabled")
 rules.add_perm("core.invite_enabled", invite_enabled_predicate)
 
-accept_invite_predicate = invite_enabled_predicate
+accept_invite_predicate = has_person & invite_enabled_predicate
 rules.add_perm("core.accept_invite_rule", accept_invite_predicate)
 
-invite_predicate = invite_enabled_predicate & has_person & has_global_perm("core.invite")
+invite_predicate = has_person & invite_enabled_predicate & has_global_perm("core.invite")
 rules.add_perm("core.invite_rule", invite_predicate)
 
 # OAuth2 permissions
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 66dab37884196b9048dc81178d2bfa3ce8ebdd13..a4ed8158083184e0451e1b8d03dab25bc581d0ef 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -19,7 +19,6 @@ from .installed_apps import AppType
 from .message import MessageType
 from .notification import MarkNotificationReadMutation, NotificationType
 from .pdf import PDFFileType
-from .permissions import GlobalPermissionType
 from .person import PersonMutation, PersonType
 from .school_term import SchoolTermType  # noqa
 from .search import SearchResultType
@@ -54,10 +53,6 @@ class Query(graphene.ObjectType):
 
     custom_menu_by_name = graphene.Field(CustomMenuType, name=graphene.String())
 
-    global_permissions_by_name = graphene.List(
-        GlobalPermissionType, permissions=graphene.List(graphene.String)
-    )
-
     def resolve_notifications(root, info, **kwargs):
         return Notification.objects.filter(
             Q(
@@ -136,12 +131,6 @@ class Query(graphene.ObjectType):
     def resolve_custom_menu_by_name(root, info, name, **kwargs):
         return CustomMenu.get_default(name)
 
-    def resolve_global_permissions_by_name(root, info, permissions, **kwargs):
-        return [
-            {"name": permission_name, "result": info.context.user.has_perm(permission_name)}
-            for permission_name in permissions
-        ]
-
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
diff --git a/aleksis/core/schema/user.py b/aleksis/core/schema/user.py
index 33547acf8a0f0ea5141916d15f2636783da594ec..1c1ff08ea2c4c77a6ab68af9e389a36db66a764c 100644
--- a/aleksis/core/schema/user.py
+++ b/aleksis/core/schema/user.py
@@ -1,5 +1,6 @@
 import graphene
 
+from .permissions import GlobalPermissionType
 from .person import PersonType
 
 
@@ -13,3 +14,13 @@ class UserType(graphene.ObjectType):
     is_anonymous = graphene.Boolean(required=True)
 
     person = graphene.Field(PersonType)
+
+    global_permissions_by_name = graphene.List(
+        GlobalPermissionType, permissions=graphene.List(graphene.String)
+    )
+
+    def resolve_global_permissions_by_name(root, info, permissions, **kwargs):
+        return [
+            {"name": permission_name, "result": info.context.user.has_perm(permission_name)}
+            for permission_name in permissions
+        ]
diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html
index 6c35b2b943881e1f433830fc723f5380254a4d67..8adfdd6a0f9cdcbbd25dc088969ccd1a5fe252e1 100644
--- a/aleksis/core/templates/core/base.html
+++ b/aleksis/core/templates/core/base.html
@@ -51,9 +51,8 @@
 
   <title>
     {% block no_browser_title %}
-      {% block browser_title %}{% endblock %} —
+      {% block browser_title %}{% endblock %}
     {% endblock %}
-    {{ request.site.preferences.general__title }}
   </title>
 
 
@@ -83,6 +82,33 @@
       window.parent.postMessage({height: $(document).height()});
     };
     window.onresize = documentResizePostMessage;
+
+    function findLink(el) {
+      if (el.href) {
+        return el.href;
+      } else if (el.parentElement) {
+        return findLink(el.parentElement);
+      } else {
+        return null;
+      }
+    };
+
+    function clickCallback(e) {
+      const link = findLink(e.target);
+      if (link == null) {
+        return;
+      }
+
+      const newUrl = new URL(link);
+      const currentUrl = new URL(window.location.href);
+      if(newUrl.origin !== currentUrl.origin) {
+        console.debug("External link clicked. Redirecting to " + link + " in new tab.");
+        e.preventDefault();
+        window.open(link, '_blank');
+      }
+    };
+
+    document.addEventListener('click', clickCallback, false);
   })
 </script>
 </body>
diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py
index 7805829f06b522c77d767990e83d549195429e7d..606ca372cea1c14641632125dcee346e73e00daf 100644
--- a/aleksis/core/util/frontend_helpers.py
+++ b/aleksis/core/util/frontend_helpers.py
@@ -34,7 +34,7 @@ def write_vite_values(out_path: str) -> dict[str, Any]:
             vite_values["appDetails"][app]["name"] = app.split(".")[-1]
             vite_values["appDetails"][app]["assetDir"] = path
             vite_values["appDetails"][app]["hasMessages"] = os.path.exists(
-                os.path.join(path, "messages.json")
+                os.path.join(path, "messages", "en.json")
             )
     # Add core entrypoint
     vite_values["coreAssetDir"] = os.path.join(settings.BASE_DIR, "aleksis", "core", "assets")
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index f33c3d631d71e7909994f35147853c0ee3ca1822..fa51a4854937f3896e50ea308037009a8f391e77 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -98,7 +98,7 @@ export default defineConfig({
     strictPort: true,
     origin: "http://localhost:5173",
     watch: {
-      ignored: ["**/*.py", "**/__pycache__/**"],
+      ignored: ["**/*.py", "**/__pycache__/**", "**/*.mo", "**/.venv/**", "**/.tox/**"],
     },
     fs: {
       allow: [