diff --git a/.eslintrc.js b/.eslintrc.js
index 15b0fbe2891c2f685e867206fc6954620237b5d6..4c2043012828bd16438eb4f36472ad48460eb6e4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,9 +1,15 @@
 module.exports = {
   extends: [
-    'plugin:vue/strongly-recommended',
+    "eslint:recommended",
+    "plugin:vue/strongly-recommended",
+    "prettier",
   ],
   rules: {
-    'vue/no-unused-vars': 'off',
-    'vue/multi-word-component-names': 'off'
-  }
-}
+    "vue/no-unused-vars": "off",
+    "vue/multi-word-component-names": "off",
+  },
+  env: {
+    browser: true,
+    node: true,
+  },
+};
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 564449be1717a97282b53b2d94f9f234b34261c2..cf02f39e36aa6fa715f8e3c73c8069433c44f497 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,21 +1,21 @@
 include:
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/general.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/prepare/lock.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/test.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/lint.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/test/security.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/build/dist.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/publish/pypi.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: /ci/docker/image.yml
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/deploy/review.yml"
-    - project: "AlekSIS/official/AlekSIS"
-      file: "/ci/deploy/trigger_dist.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/general.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/prepare/lock.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/test.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/lint.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/test/security.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/build/dist.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/publish/pypi.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: /ci/docker/image.yml
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/deploy/review.yml"
+  - project: "AlekSIS/official/AlekSIS"
+    file: "/ci/deploy/trigger_dist.yml"
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..81a9d0fcb7eb41ce3cccfe351836df790bd2203c
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,87 @@
+# Byte-compiled / optimized / DLL files
+*$py.class
+*.py[cod]
+__pycache__/
+
+# Distribution / packaging
+*.egg
+*.egg-info/
+.Python
+.eggs/
+.installed.cfg
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+
+# Installer logs
+pip-delete-this-directory.txt
+pip-log.txt
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# pyenv
+.python-version
+
+# Environments
+.env
+.venv
+ENV/
+env/
+venv/
+
+# Editors
+*~
+DEADJOE
+\#*#
+
+# IntelliJ
+.idea
+.idea/
+
+# Database
+db.sqlite3
+
+# Sphinx
+docs/_build/
+
+# TeX
+*.aux
+
+# Generated files
+/node_modules/
+/static/
+/whoosh_index/
+poetry.lock
+
+.coverage
+.mypy_cache/
+.tox/
+htmlcov/
+maintenance_mode_state.txt
+media/
+package-lock.json
+yarn.lock
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
+
+/cache
+
+# Add HTML files to avoid problems with unsupported Django templates
+*.html
diff --git a/.stylelintrc.json b/.stylelintrc.json
index 40db42c6689bd157e91cec65fda28693350b6332..2e8ff5864a48be6a22bd1742c2317556a8ec9419 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -1,3 +1,3 @@
 {
-  "extends": "stylelint-config-standard"
+  "extends": ["stylelint-config-standard", "stylelint-config-prettier"]
 }
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index be635c87bb802eb8347653cc9936904d752a0b89..fcc0f0872e6c82eb6d1eee1a76f279299ed2b1f9 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -27,7 +27,10 @@ Changed
 Fixed
 ~~~~~
 
-* Error message in permission form was misleading.
+* The system tried to send notifications for done background tasks
+  in addition to tasks started in the foreground.
+* Invitations for existing short name did not work.
+* Invitations for persons without pre-defined e-mail address did not behave correctly
 
 Removed
 ~~~~~~~
@@ -37,6 +40,44 @@ Removed
   * It caused major performance issues and is not useful with the new
     frontend anymore
 
+`2.12.1`_ - 2022-11-06
+----------------------
+
+Fixed
+~~~~~
+
+* An invalid backport caused OIDC clients without PKCD to fail.
+
+`2.12`_ - 2022-11-04
+--------------------
+
+Added
+~~~~~
+
+* Show also group ownerships on person detail page
+* [Dev] Provide plain PDF template without header/footer for special layouts.
+* [Dev] Introduce support for reformattinga and linting JS, Vue, and CSS files.
+
+Changed
+~~~~~~~
+
+* OIDC scope "profile" now exposes the avatar instead of the official photo
+* Language selection on Vue pages now runs via GraphQL queries.
+* [Dev] Provide function to generate PDF files from fully-rendered templates.
+* [Dev] Accept pre-created file object for PDF generation to define
+  the redirect URL in advance.
+
+Fixed
+~~~~~
+
+* The logo in the PDF files was displayed at the wrong position.
+* Sometimes the PDF files were not generated correctly
+  and images were displayed only partially.
+* Error message in permission form was misleading.
+* Personal invites did not work
+* Invite Person view threw an error when personal invites existed
+* Detailed information for done Celery tasks weren't saved.
+
 `2.11`_ - 2022-08-27
 --------------------
 
@@ -947,3 +988,6 @@ Fixed
 .. _2.10.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.1
 .. _2.10.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.2
 .. _2.11: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11
+.. _2.11.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11.1
+.. _2.12: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12
+.. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1
diff --git a/Dockerfile b/Dockerfile
index df38046b1aff30d14a480603c80d5d2987cf4cdd..914ee330f0d7e6646ba62c4d630747501b72d4f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,6 +30,7 @@ RUN apt-get -y update && \
     eatmydata apt-get install -y --no-install-recommends \
         build-essential \
         chromium \
+        chromium-driver \
         curl \
 	dumb-init \
 	gettext \
diff --git a/README.rst b/README.rst
index 290669c5779841ac7713f7f8004cc3141de0b1f9..a7a156f734ab9bc96e0b549177587e22d848898b 100644
--- a/README.rst
+++ b/README.rst
@@ -68,9 +68,10 @@ Licence
   Copyright © 2019, 2020, 2021, 2022 Dominik George <dominik.george@teckids.org>
   Copyright © 2019, 2020, 2021, 2022 Tom Teichler <tom.teichler@teckids.org>
   Copyright © 2019 mirabilos <thorsten.glaser@teckids.org>
+  Copyright © 2021, 2022 magicfelix <felix@felix-zauberer.de>
   Copyright © 2021 Lloyd Meins <meinsll@katharineum.de>
-  Copyright © 2021 magicfelix <felix@felix-zauberer.de>
   Copyright © 2022 Benedict Suska <benedict.suska@teckids.org>
+  Copyright © 2022 Lukas Weichelt <lukas.weichelt@teckids.org>
 
   Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany).
 
diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index 4e7229d81e00d4f5d9f4ecccce86a703bee6befa..096d7effb318402480dcb35bb244a96d59b0d156 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -43,9 +43,10 @@ class CoreConfig(AppConfig):
         ([2019, 2020, 2021, 2022], "Dominik George", "dominik.george@teckids.org"),
         ([2019, 2020, 2021, 2022], "Tom Teichler", "tom.teichler@teckids.org"),
         ([2019], "mirabilos", "thorsten.glaser@teckids.org"),
+        ([2021, 2022], "magicfelix", "felix@felix-zauberer.de"),
         ([2021], "Lloyd Meins", "meinsll@katharineum.de"),
-        ([2021], "magicfelix", "felix@felix-zauberer.de"),
         ([2022], "Benedict Suska", "benedict.suska@teckids.org"),
+        ([2022], "Lukas Weichelt", "lukas.weichelt@teckids.org"),
     )
 
     def ready(self):
diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js
index 32d4f1b0ed34fea9327b7b1c83257b15009eabd1..3fda256d7740305515809b037d1868d4e7792305 100644
--- a/aleksis/core/assets/app.js
+++ b/aleksis/core/assets/app.js
@@ -1,131 +1,141 @@
-import Vue from "vue"
-import VueRouter from "vue-router"
-import Vuetify from "vuetify"
-import "vuetify/dist/vuetify.min.css"
+import Vue from "vue";
+import VueRouter from "vue-router";
+import Vuetify from "vuetify";
+import "vuetify/dist/vuetify.min.css";
 
-import ApolloClient from 'apollo-boost'
-import VueApollo from 'vue-apollo'
-import gql from 'graphql-tag'
+import ApolloClient from "apollo-boost";
+import VueApollo from "vue-apollo";
 
-import "./css/global.scss"
-import VueI18n from 'vue-i18n'
+import "./css/global.scss";
+import VueI18n from "vue-i18n";
 
-import messages from "./messages.json"
+import messages from "./messages.json";
 
-Vue.use(VueI18n)
+Vue.use(VueI18n);
 
 const i18n = new VueI18n({
-    locale: "en",
-    fallbackLocale: "en",
-    messages
+  locale: "en",
+  fallbackLocale: "en",
+  messages,
 });
 
 // Using this function, apps can register their locale files
 i18n.registerLocale = function (messages) {
-    for (let locale in messages) {
-        i18n.mergeLocaleMessage(locale, messages[locale]);
-    }
+  for (let locale in messages) {
+    i18n.mergeLocaleMessage(locale, messages[locale]);
+  }
 };
 
-Vue.use(Vuetify)
-Vue.use(VueRouter)
+Vue.use(Vuetify);
+Vue.use(VueRouter);
 
 const vuetify = new Vuetify({
-    // TODO: load theme data dynamically
-    //  - find a way to load template context data
-    //  - include all site preferences
-    //  - load menu stuff to render the sidenav
-    icons: {
-        iconfont: 'mdi', // default - only for display purposes
-        values: {
-          cancel: 'mdi-close-circle-outline',
-          delete: 'mdi-close-circle-outline',
-          success: 'mdi-check-circle-outline',
-          info: 'mdi-information-outline',
-          warning: 'mdi-alert-outline',
-          error: 'mdi-alert-octagon-outline',
-          prev: 'mdi-chevron-left',
-          next: 'mdi-chevron-right',
-          checkboxOn: 'mdi-checkbox-marked-outline',
-          checkboxIndeterminate: 'mdi-minus-box-outline',
-          edit: 'mdi-pencil-outline',
-        },
+  // TODO: load theme data dynamically
+  //  - find a way to load template context data
+  //  - include all site preferences
+  //  - load menu stuff to render the sidenav
+  icons: {
+    iconfont: "mdi", // default - only for display purposes
+    values: {
+      cancel: "mdi-close-circle-outline",
+      delete: "mdi-close-circle-outline",
+      success: "mdi-check-circle-outline",
+      info: "mdi-information-outline",
+      warning: "mdi-alert-outline",
+      error: "mdi-alert-octagon-outline",
+      prev: "mdi-chevron-left",
+      next: "mdi-chevron-right",
+      checkboxOn: "mdi-checkbox-marked-outline",
+      checkboxIndeterminate: "mdi-minus-box-outline",
+      edit: "mdi-pencil-outline",
     },
-    theme: {
-        dark: JSON.parse(document.getElementById("design-mode").textContent) === "dark",
-        themes: {
-            light: {
-                primary: JSON.parse(document.getElementById("primary-color").textContent),
-                secondary: JSON.parse(document.getElementById("secondary-color").textContent),
-            },
-            dark: {
-                primary: JSON.parse(document.getElementById("primary-color").textContent),
-                secondary: JSON.parse(document.getElementById("secondary-color").textContent),
-            },
-        },
+  },
+  theme: {
+    dark:
+      JSON.parse(document.getElementById("design-mode").textContent) === "dark",
+    themes: {
+      light: {
+        primary: JSON.parse(
+          document.getElementById("primary-color").textContent
+        ),
+        secondary: JSON.parse(
+          document.getElementById("secondary-color").textContent
+        ),
+      },
+      dark: {
+        primary: JSON.parse(
+          document.getElementById("primary-color").textContent
+        ),
+        secondary: JSON.parse(
+          document.getElementById("secondary-color").textContent
+        ),
+      },
     },
-})
+  },
+});
 
 const apolloClient = new ApolloClient({
-  uri: JSON.parse(document.getElementById("graphql-url").textContent)
-})
+  uri: JSON.parse(document.getElementById("graphql-url").textContent),
+});
 
 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";
 
 Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
 
-Vue.use(VueApollo)
+Vue.use(VueApollo);
 
 const apolloProvider = new VueApollo({
   defaultClient: apolloClient,
-})
+});
 
 const router = new VueRouter({
   mode: "history",
-//  routes: [
-//    { path: "/", component: "TheApp" },
-//  }
+  //  routes: [
+  //    { path: "/", component: "TheApp" },
+  //  }
 });
 
 const app = new Vue({
-    el: '#app',
-    apolloProvider,
-    vuetify: vuetify,
-    // delimiters: ["<%","%>"] // FIXME: discuss new delimiters, [[ <% [{ {[ <[ (( …
-    data: () => ({
-        drawer: vuetify.framework.breakpoint.lgAndUp,
-        group: null, // what does this mean?
-        urls: window.Urls,
-        django: window.django,
-        // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere
-        showCacheAlert: false,
-        systemProperties: {
-            currentLanguage: "en",
-            availableLanguages: [],
-        },
-    }),
-    apollo: {
-        systemProperties: require("./systemProperties.graphql"),
+  el: "#app",
+  apolloProvider,
+  vuetify: vuetify,
+  // delimiters: ["<%","%>"] // FIXME: discuss new delimiters, [[ <% [{ {[ <[ (( …
+  data: () => ({
+    drawer: vuetify.framework.breakpoint.lgAndUp,
+    group: null, // what does this mean?
+    urls: window.Urls,
+    django: window.django,
+    // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere
+    showCacheAlert: false,
+    systemProperties: {
+      currentLanguage: "en",
+      availableLanguages: [],
     },
-    watch: {
-        systemProperties: function (newProperties) {
-            this.$i18n.locale = newProperties.currentLanguage;
-            this.$vuetify.lang.current = newProperties.currentLanguage;
-        }
+  }),
+  apollo: {
+    systemProperties: require("./systemProperties.graphql"),
+  },
+  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,
-    },
-    router,
-    i18n
-})
+  },
+  components: {
+    "cache-notification": CacheNotification,
+    "language-form": LanguageForm,
+    "notification-list": NotificationList,
+    "sidenav-search": SidenavSearch,
+    CeleryProgressBottom,
+  },
+  router,
+  i18n,
+});
 
 window.app = app;
 window.router = router;
diff --git a/aleksis/core/assets/components/BackButton.vue b/aleksis/core/assets/components/BackButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..eaa305f7c3c8f2ca17f53f709870459e9cc790bd
--- /dev/null
+++ b/aleksis/core/assets/components/BackButton.vue
@@ -0,0 +1,12 @@
+<template>
+  <v-btn color="secondary" v-bind="$attrs">
+    <v-icon left>mdi-chevron-left</v-icon>
+    {{ $t("actions.back") }}
+  </v-btn>
+</template>
+
+<script>
+export default {
+  name: "BackButton",
+};
+</script>
diff --git a/aleksis/core/assets/components/CacheNotification.vue b/aleksis/core/assets/components/CacheNotification.vue
index d636a056cab92f3464a602d9a02af923197c2a35..f30adbd0bf8f02016216d9285382f7e68e30649c 100644
--- a/aleksis/core/assets/components/CacheNotification.vue
+++ b/aleksis/core/assets/components/CacheNotification.vue
@@ -1,25 +1,25 @@
 <template>
   <message-box :value="cache" type="warning">
-    {{ $t('alerts.page_cached') }}
+    {{ $t("alerts.page_cached") }}
   </message-box>
 </template>
 
 <script>
-  export default {
-    name: "cache-notification",
-    data () {
-        return {
-            cache: false,
-        }
-    },
-    created() {
-        this.channel = new BroadcastChannel("cache-or-not");
-        this.channel.onmessage = (event) => {
-            this.cache = event.data === true;
-        }
-    },
-      destroyed(){
-        this.channel.close()
-      },
-  }
+export default {
+  name: "CacheNotification",
+  data() {
+    return {
+      cache: false,
+    };
+  },
+  created() {
+    this.channel = new BroadcastChannel("cache-or-not");
+    this.channel.onmessage = (event) => {
+      this.cache = event.data === true;
+    };
+  },
+  destroyed() {
+    this.channel.close();
+  },
+};
 </script>
diff --git a/aleksis/core/assets/components/LanguageForm.vue b/aleksis/core/assets/components/LanguageForm.vue
index 2897d3a3057c8e11211bc16149b137aa573e1021..d9b0d0b707e279500701350affb843388161e1ba 100644
--- a/aleksis/core/assets/components/LanguageForm.vue
+++ b/aleksis/core/assets/components/LanguageForm.vue
@@ -1,31 +1,31 @@
 <template>
   <v-menu offset-y>
-    <template v-slot:activator="{ on, attrs }">
-      <v-btn
-          depressed
-          v-bind="attrs"
-          v-on="on"
-          color="primary"
-      >
+    <template #activator="{ on, attrs }">
+      <v-btn depressed v-bind="attrs" v-on="on" color="primary">
         <v-icon icon color="white">mdi-translate</v-icon>
         {{ $i18n.locale }}
       </v-btn>
     </template>
     <v-list id="language-dropdown" class="dropdown-content" min-width="150">
       <v-skeleton-loader
-          v-if="!$root.systemProperties.availableLanguages"
-          class="mx-auto"
-          type="list-item, list-item, list-item"
+        v-if="!$root.systemProperties.availableLanguages"
+        class="mx-auto"
+        type="list-item, list-item, list-item"
       ></v-skeleton-loader>
       <v-list-item-group
-          v-if="$root.systemProperties.availableLanguages"
-          v-model="$i18n.locale"
-          color="primary"
+        v-if="$root.systemProperties.availableLanguages"
+        v-model="$i18n.locale"
+        color="primary"
       >
-        <v-list-item v-for="languageOption in $root.systemProperties.availableLanguages" :key="languageOption.code"
-                     :value="languageOption.code"
-                     @click="setLanguage(languageOption)">
-          <v-list-item-title>{{ languageOption.nameTranslated }}</v-list-item-title>
+        <v-list-item
+          v-for="languageOption in $root.systemProperties.availableLanguages"
+          :key="languageOption.code"
+          :value="languageOption.code"
+          @click="setLanguage(languageOption)"
+        >
+          <v-list-item-title>{{
+            languageOption.nameTranslated
+          }}</v-list-item-title>
         </v-list-item>
       </v-list-item-group>
     </v-list>
@@ -36,8 +36,8 @@
 export default {
   data: function () {
     return {
-      language: this.$i18n.locale
-    }
+      language: this.$i18n.locale,
+    };
   },
   methods: {
     setLanguage: function (languageOption) {
@@ -46,6 +46,6 @@ export default {
       this.$vuetify.lang.current = languageOption.code;
     },
   },
-  name: "language-form",
-}
+  name: "LanguageForm",
+};
 </script>
diff --git a/aleksis/core/assets/components/MessageBox.vue b/aleksis/core/assets/components/MessageBox.vue
index 2a4cb17295835f5d3d1eb6f310a935eb4c2789be..4c79f4d51a36123f4e91f4e187e594d83387e1d3 100644
--- a/aleksis/core/assets/components/MessageBox.vue
+++ b/aleksis/core/assets/components/MessageBox.vue
@@ -1,15 +1,15 @@
 <script>
-  export default {
-    name: "message-box",
-    // Due to this component being a wrapper to a v-alert, all props of this can be used (and overridden).
-  }
+export default {
+  name: "MessageBox",
+  // Due to this component being a wrapper to a v-alert, all props of this can be used (and overridden).
+};
 </script>
 
 <template>
-    <v-alert border="left" text v-bind="$attrs">
-      <slot>
-        Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
-      </slot>
-    </v-alert>
+  <v-alert border="left" text v-bind="$attrs">
+    <slot>
+      Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
+      tempor incididunt ut labore et dolore magna aliqua.
+    </slot>
+  </v-alert>
 </template>
-
diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue
index 30fdbe9596487061d962c82af968f659fa524e5f..58bf7bf6d718302279cd9d2d5d9451b6bdf9a644 100644
--- a/aleksis/core/assets/components/SidenavSearch.vue
+++ b/aleksis/core/assets/components/SidenavSearch.vue
@@ -1,21 +1,68 @@
 <script>
-  export default {
-    methods: {
-        submit: function () {
-            this.$refs.form.submit()
-        },
+export default {
+  methods: {
+    submit: function () {
+      this.$refs.form.submit();
     },
-    props: ["action", "placeholder"],
-    name: "sidenav-search",
-  }
-  // FIXME: implement suggestions etc, use "loading" attribute
+  },
+  props: {
+    action: {
+      type: String,
+      required: true,
+    },
+  },
+  name: "SidenavSearch",
+  data() {
+    return {
+      q: "",
+    };
+  },
+};
 </script>
 
 <template>
-  <form method="get" ref="form" :action="action" id="search-form">
-    <v-text-field
-        :append-icon="'mdi-magnify'" @click:append="submit" single-line
-        id="search" name="q" type="search" enterkeyhint="search" :placeholder="placeholder"
-    ></v-text-field>
-  </form>
+  <ApolloQuery
+    :query="require('./searchSnippets.graphql')"
+    :variables="{
+      q,
+    }"
+    :skip="!q"
+  >
+    <template #default="{ result: { error, data }, isLoading, query }">
+      <form method="get" ref="form" :action="action" id="search-form">
+        <input type="hidden" name="q" :value="q" />
+        <v-autocomplete
+          :prepend-icon="'mdi-magnify'"
+          append-icon=""
+          @click:prepend="submit"
+          single-line
+          clearable
+          :loading="!!isLoading"
+          id="search"
+          type="search"
+          enterkeyhint="search"
+          :label="$t('actions.search')"
+          :search-input.sync="q"
+          flat
+          solo
+          cache-items
+          hide-no-data
+          hide-details
+          :items="data ? data.searchSnippets : undefined"
+        >
+          <template #item="{ item }">
+            <v-list-item :href="item.obj.absoluteUrl">
+              <v-list-item-icon v-if="item.obj.icon">
+                <v-icon>{{ "mdi-" + item.obj.icon }}</v-icon>
+              </v-list-item-icon>
+              <v-list-item-content>
+                <v-list-item-title> {{ item.obj.name }}</v-list-item-title>
+                <v-list-item-subtitle>{{ item.text }}</v-list-item-subtitle>
+              </v-list-item-content>
+            </v-list-item>
+          </template>
+        </v-autocomplete>
+      </form>
+    </template>
+  </ApolloQuery>
 </template>
diff --git a/aleksis/core/assets/components/about/About.vue b/aleksis/core/assets/components/about/About.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6187d7c84503ce8633dc9a9559a88ac86b725d37
--- /dev/null
+++ b/aleksis/core/assets/components/about/About.vue
@@ -0,0 +1,16 @@
+<template>
+  <div class="mt-4 mb-4">
+    <about-aleksis></about-aleksis>
+    <installed-apps-list />
+  </div>
+</template>
+
+<script>
+import InstalledAppsList from "./InstalledAppsList.vue";
+import AboutAleksis from "./AboutAleksis.vue";
+
+export default {
+  name: "About",
+  components: { AboutAleksis, InstalledAppsList },
+};
+</script>
diff --git a/aleksis/core/assets/components/about/AboutAleksis.vue b/aleksis/core/assets/components/about/AboutAleksis.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2f39581d430a06b57bec0a9367983023ddbb9f95
--- /dev/null
+++ b/aleksis/core/assets/components/about/AboutAleksis.vue
@@ -0,0 +1,65 @@
+<template>
+  <v-row class="mb-3">
+    <v-col cols="12">
+      <v-card class="d-flex flex-column">
+        <v-card-title>{{ $t("about.about_aleksis") }}</v-card-title>
+        <v-card-text>
+          <p class="text-body-1">
+            {{ $t("about.about_aleksis_1") }}
+          </p>
+          <p class="text-body-1">
+            {{ $t("about.about_aleksis_2") }}
+          </p>
+        </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-card-actions>
+      </v-card>
+    </v-col>
+    <v-col cols="12">
+      <v-card class="d-flex flex-column">
+        <v-card-title>{{ $t("about.licence_information") }}</v-card-title>
+        <v-card-text>
+          <p>
+            {{ $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>
+          </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://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers"
+          >
+            {{ $t("about.more_information_eupl") }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-col>
+  </v-row>
+</template>
+
+<script>
+export default {
+  name: "AboutAleksis",
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/assets/components/about/InstalledAppCard.vue b/aleksis/core/assets/components/about/InstalledAppCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..796808abd127324caf48d71e5e79afcc24da8380
--- /dev/null
+++ b/aleksis/core/assets/components/about/InstalledAppCard.vue
@@ -0,0 +1,131 @@
+<template>
+  <v-col cols="12" md="6" lg="6" xl="4" class="d-flex align-stretch">
+    <v-card :id="app.name" class="d-flex flex-column flex-grow-1">
+      <v-card-title>
+        {{ app.verboseName }}
+      </v-card-title>
+
+      <v-card-subtitle class="text-body-1 black--text">
+        {{ app.version }}
+      </v-card-subtitle>
+
+      <v-card-text>
+        <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>
+          </v-col>
+          <v-col cols="6">
+            {{ $t("about.licence_type") }} <br />
+            <v-chip
+              v-if="app.licence.flags.isFsfLibre"
+              color="green"
+              text-color="white"
+              small
+            >
+              {{ $t("about.free_software") }}
+            </v-chip>
+            <v-chip
+              v-else-if="app.licence.flags.isOsiApproved"
+              color="green"
+              text-color="white"
+              small
+            >
+              {{ $t("about.open_source") }}
+            </v-chip>
+            <v-chip v-else color="orange" text-color="white" small>
+              {{ $t("about.proprietary") }}
+            </v-chip>
+          </v-col>
+
+          <v-col cols="12" v-if="app.licence.licences.length !== 0">
+            {{ $t("about.licence_consists_of") }}
+            <div
+              v-for="licence in app.licence.licences"
+              class="mb-2"
+              :key="licence.name"
+            >
+              <v-chip
+                v-if="licence.isOsiApproved || licence.isFsfLibre"
+                color="green"
+                text-color="green"
+                outlined
+                small
+                :href="licence.url"
+              >
+                {{ licence.name }}
+              </v-chip>
+              <v-chip
+                v-else
+                color="orange"
+                text-color="orange"
+                outlined
+                :href="licence.url"
+              >
+                {{ licence.name }}
+              </v-chip>
+            </div>
+          </v-col>
+        </v-row>
+      </v-card-text>
+
+      <v-spacer></v-spacer>
+
+      <v-card-actions v-if="app.urls.length !== 0">
+        <v-btn text color="primary" @click="reveal = true">
+          Show copyright
+        </v-btn>
+        <v-btn
+          v-for="url in app.urls"
+          color="primary"
+          text
+          :href="url.url"
+          :key="url.url"
+        >
+          {{ url.name }}
+        </v-btn>
+      </v-card-actions>
+
+      <v-expand-transition>
+        <v-card
+          v-if="reveal"
+          class="transition-fast-in-fast-out v-card--reveal d-flex flex-column"
+        >
+          <v-card-text class="pb-0">
+            <v-row>
+              <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>
+                  <br />
+                </span>
+              </v-col>
+            </v-row>
+          </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-card-actions>
+        </v-card>
+      </v-expand-transition>
+    </v-card>
+  </v-col>
+</template>
+<script>
+export default {
+  name: "InstalledAppCard",
+  data: () => ({
+    reveal: false,
+  }),
+  props: {
+    app: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/about/InstalledAppsList.vue b/aleksis/core/assets/components/about/InstalledAppsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..491e3df15ece49cf736f037212a0bc668a924bed
--- /dev/null
+++ b/aleksis/core/assets/components/about/InstalledAppsList.vue
@@ -0,0 +1,39 @@
+<template>
+  <ApolloQuery :query="require('./installedApps.graphql')">
+    <template #default="{ result: { error, data }, isLoading }">
+      <v-row v-if="isLoading">
+        <v-col
+          v-for="idx in 3"
+          :key="idx"
+          cols="12"
+          md="6"
+          lg="6"
+          xl="4"
+          class="d-flex align-stretch"
+        >
+          <v-card class="d-flex flex-column flex-grow-1 pa-4">
+            <v-skeleton-loader
+              type="heading, actions, text@5"
+            ></v-skeleton-loader>
+          </v-card>
+        </v-col>
+      </v-row>
+      <v-row v-if="data.installedApps">
+        <installed-app-card
+          v-for="app in data.installedApps"
+          :key="app.name"
+          :app="app"
+        />
+      </v-row>
+    </template>
+  </ApolloQuery>
+</template>
+
+<script>
+import InstalledAppCard from "./InstalledAppCard.vue";
+
+export default {
+  name: "InstalledAppsList",
+  components: { InstalledAppCard },
+};
+</script>
diff --git a/aleksis/core/assets/components/about/installedApps.graphql b/aleksis/core/assets/components/about/installedApps.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..01ceaf99eb8d79d44ee7014d9f582d7dfeb54ace
--- /dev/null
+++ b/aleksis/core/assets/components/about/installedApps.graphql
@@ -0,0 +1,29 @@
+{
+  installedApps {
+    name
+    verboseName
+    version
+    copyrights {
+      years
+      name
+      email
+    }
+    licence {
+      verboseName
+      flags {
+        isFsfLibre
+        isOsiApproved
+      }
+      licences {
+        isFsfLibre
+        isOsiApproved
+        name
+        url
+      }
+    }
+    urls {
+      name
+      url
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
new file mode 100644
index 0000000000000000000000000000000000000000..06b28fc37f52f5440442815207d901fe42cdcd69
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
@@ -0,0 +1,130 @@
+<template>
+  <v-row>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+    <v-col sm="12" md="10" lg="8" xl="6">
+      <v-card :loading="$apollo.loading">
+        <v-card-title v-if="progress">
+          {{ progress.meta.title }}
+        </v-card-title>
+        <v-card-text v-if="progress">
+          <v-progress-linear
+            :value="progress.progress.percent"
+            buffer-value="0"
+            color="primary"
+            class="mb-2"
+            stream
+          />
+          <div class="text-center mb-4">
+            {{
+              progress.meta.progressTitle
+                ? progress.meta.progressTitle
+                : $t("celery_progress.progress_title")
+            }}
+          </div>
+          <div v-if="progress">
+            <message-box
+              v-for="(message, idx) in progress.messages"
+              dense
+              :type="message.tag"
+              transition="slide-x-transition"
+              :key="idx"
+            >
+              {{ message.message }}
+            </message-box>
+          </div>
+          <message-box
+            v-if="progress.state === 'ERROR'"
+            dense
+            type="error"
+            transition="slide-x-transition"
+          >
+            {{
+              progress.meta.errorMessage
+                ? progress.meta.errorMessage
+                : $t("celery_progress.error_message")
+            }}
+          </message-box>
+          <message-box
+            v-if="progress.state === 'SUCCESS'"
+            dense
+            type="success"
+            transition="slide-x-transition"
+          >
+            {{
+              progress.meta.successMessage
+                ? progress.meta.successMessage
+                : $t("celery_progress.success_message")
+            }}
+          </message-box>
+        </v-card-text>
+        <v-card-actions
+          v-if="
+            progress &&
+            (progress.state === 'ERROR' || progress.state === 'SUCCESS')
+          "
+        >
+          <back-button :href="progress.meta.backUrl" text />
+          <v-spacer />
+          <v-btn
+            v-if="progress.meta.additionalButton"
+            :href="progress.meta.additionalButton.url"
+            text
+            color="primary"
+          >
+            <v-icon v-if="progress.meta.additionalButton.icon" left>
+              {{ progress.meta.additionalButton.icon }}
+            </v-icon>
+            {{ progress.meta.additionalButton.title }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-col>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+  </v-row>
+</template>
+
+<script>
+import BackButton from "../BackButton.vue";
+import MessageBox from "../MessageBox.vue";
+
+export default {
+  name: "CeleryProgress",
+  components: { BackButton, MessageBox },
+  apollo: {
+    celeryProgressByTaskId: {
+      query: require("./celeryProgress.graphql"),
+      variables() {
+        return {
+          taskId: this.$route.params.taskId,
+        };
+      },
+      pollInterval: 1000,
+    },
+  },
+  computed: {
+    progress() {
+      return this.celeryProgressByTaskId;
+    },
+    state() {
+      return this.progress ? this.progress.state : null;
+    },
+  },
+  watch: {
+    state(newState) {
+      if (newState === "SUCCESS" || newState === "ERROR") {
+        this.$apollo.queries.celeryProgressByTaskId.stopPolling();
+        this.$apollo.mutate({
+          mutation: require("./celeryProgressFetched.graphql"),
+          variables: {
+            taskId: this.$route.params.taskId,
+          },
+        });
+      }
+      if (newState === "SUCCESS" && this.progress.meta.redirectOnSuccessUrl) {
+        window.location.replace(this.progress.meta.redirectOnSuccessUrl);
+        // FIXME this.$router.push(this.progress.meta.redirectOnSuccessUrl);
+      }
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2bb95431cecd66bc68789bc1bfe7c5977b68cfb6
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
@@ -0,0 +1,53 @@
+<template>
+  <v-bottom-sheet :value="show" persistent hide-overlay max-width="400px">
+    <v-expansion-panels accordion v-model="open">
+      <v-expansion-panel>
+        <v-expansion-panel-header color="primary" class="white--text px-4">
+          {{
+            $tc("celery_progress.running_tasks", numberOfTasks, {
+              number: numberOfTasks,
+            })
+          }}
+        </v-expansion-panel-header>
+        <v-expansion-panel-content>
+          <div class="mx-n6 mb-n4" v-if="celeryProgressByUser">
+            <task-list-item
+              v-for="task in celeryProgressByUser"
+              :task="task"
+              :key="task.meta.taskId"
+            />
+          </div>
+        </v-expansion-panel-content>
+      </v-expansion-panel>
+    </v-expansion-panels>
+  </v-bottom-sheet>
+</template>
+
+<script>
+import TaskListItem from "./TaskListItem.vue";
+
+export default {
+  name: "CeleryProgressBottom",
+  components: { TaskListItem },
+  data() {
+    return { open: 0 };
+  },
+  computed: {
+    show() {
+      return this.celeryProgressByUser && this.celeryProgressByUser.length > 0;
+    },
+    numberOfTasks() {
+      if (!this.celeryProgressByUser) {
+        return 0;
+      }
+      return this.celeryProgressByUser.length;
+    },
+  },
+  apollo: {
+    celeryProgressByUser: {
+      query: require("./celeryProgressBottom.graphql"),
+      pollInterval: 1000,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/celery_progress/TaskListItem.vue b/aleksis/core/assets/components/celery_progress/TaskListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a90236923904f11a37c624528b6dcfbc7b879aae
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/TaskListItem.vue
@@ -0,0 +1,37 @@
+<template>
+  <v-list-item
+    :key="task.meta.taskId"
+    :to="{ name: 'core.celery_progress', params: { taskId: task.meta.taskId } }"
+  >
+    <v-list-item-content>
+      <v-list-item-title>{{ task.meta.title }}</v-list-item-title>
+      <v-list-item-subtitle>{{ task.meta.progressTitle }}</v-list-item-subtitle>
+    </v-list-item-content>
+
+    <v-list-item-action>
+      <v-progress-circular
+        v-if="!task.complete"
+        color="primary"
+        :value="task.progress.percent"
+      ></v-progress-circular>
+      <v-icon size="32px" v-else-if="task.state === 'SUCCESS'" color="success"
+        >mdi-check-circle-outline</v-icon
+      >
+      <v-icon size="32px" v-else color="error">mdi-alert-circle-outline</v-icon>
+    </v-list-item-action>
+  </v-list-item>
+</template>
+
+<script>
+export default {
+  name: "TaskListItem",
+  props: {
+    task: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgress.graphql b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..557e33517d4f3536e043ba0e64cb8c3f622741d2
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql
@@ -0,0 +1,30 @@
+query ($taskId: String!) {
+  celeryProgressByTaskId(taskId: $taskId) {
+    state
+    success
+    progress {
+      current
+      total
+      percent
+    }
+    complete
+    messages {
+      level
+      message
+      tag
+    }
+    meta {
+      title
+      progressTitle
+      errorMessage
+      successMessage
+      redirectOnSuccessUrl
+      backUrl
+      additionalButton {
+        title
+        icon
+        url
+      }
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..5cae8f3baa46b14b4cf1031dc248d2dd7757b576
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql
@@ -0,0 +1,17 @@
+{
+  celeryProgressByUser {
+    state
+    success
+    progress {
+      current
+      total
+      percent
+    }
+    complete
+    meta {
+      taskId
+      title
+      progressTitle
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..b3fedc916e6a3851677f0fe7a3c322c9311a33e4
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql
@@ -0,0 +1,7 @@
+mutation ($taskId: String!) {
+  celeryProgressFetched(taskId: $taskId) {
+    celeryProgress {
+      state
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue
index 2035cbd69d664c59d63ab95cd39571b27842d74e..3659f1f2794382722b9e7671edd831c9bc4a0911 100644
--- a/aleksis/core/assets/components/notifications/NotificationItem.vue
+++ b/aleksis/core/assets/components/notifications/NotificationItem.vue
@@ -3,10 +3,8 @@
     :mutation="require('./markNotificationRead.graphql')"
     :variables="{ id: this.notification.id }"
   >
-    <template v-slot="{ mutate, loading, error }">
-      <v-list-item
-        v-intersect="mutate"
-      >
+    <template #default="{ mutate, loading, error }">
+      <v-list-item v-intersect="mutate">
         <v-list-item-content>
           <v-list-item-title>{{ notification.title }}</v-list-item-title>
 
@@ -22,7 +20,7 @@
 
         <v-list-item-action v-if="notification.link">
           <v-btn text :href="notification.link">
-            {{ $t('notifications.more_information') }} →
+            {{ $t("notifications.more_information") }} →
           </v-btn>
         </v-list-item-action>
 
@@ -35,9 +33,12 @@
 </template>
 
 <script>
-  export default {
-    props: {
-      notification: Object,
+export default {
+  props: {
+    notification: {
+      type: Object,
+      required: true,
     },
-  }
+  },
+};
 </script>
diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue
index cb42dc17af31525d437594e268139401a2186a00..9e057ef7f4093f01d072bb88b6e99f2cb13841f8 100644
--- a/aleksis/core/assets/components/notifications/NotificationList.vue
+++ b/aleksis/core/assets/components/notifications/NotificationList.vue
@@ -1,9 +1,9 @@
 <template>
   <ApolloQuery
     :query="require('./myNotifications.graphql')"
-    :pollInterval="1000"
+    :poll-interval="1000"
   >
-    <template v-slot="{ result: { error, data }, isLoading }">
+    <template #default="{ result: { error, data }, isLoading }">
       <v-list two-line v-if="data && data.myNotifications.notifications.length">
         <NotificationItem
           v-for="notification in data.myNotifications.notifications"
@@ -11,17 +11,19 @@
           :notification="notification"
         />
       </v-list>
-      <p v-else>{{ $root.django.gettext('No notifications available yet.') }}</p>
+      <p v-else>
+        {{ $root.django.gettext("No notifications available yet.") }}
+      </p>
     </template>
   </ApolloQuery>
 </template>
 
 <script>
-  import NotificationItem from "./NotificationItem.vue";
+import NotificationItem from "./NotificationItem.vue";
 
-  export default {
-    components: {
-      NotificationItem,
-    },
-  }
+export default {
+  components: {
+    NotificationItem,
+  },
+};
 </script>
diff --git a/aleksis/core/assets/components/searchSnippets.graphql b/aleksis/core/assets/components/searchSnippets.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..65a686624b21d4aba3d925ad7d6c48b1ab185063
--- /dev/null
+++ b/aleksis/core/assets/components/searchSnippets.graphql
@@ -0,0 +1,10 @@
+query search($q: String!) {
+  searchSnippets(query: $q, limit: 5) {
+    obj {
+      name
+      absoluteUrl
+      icon
+    }
+    text
+  }
+}
diff --git a/aleksis/core/assets/css/global.scss b/aleksis/core/assets/css/global.scss
index 9c91c57ddf7fb4e56e69daa40c142bc2ed4b589b..66e731dfe7d3a47498d4356b080dc1839898d1c1 100644
--- a/aleksis/core/assets/css/global.scss
+++ b/aleksis/core/assets/css/global.scss
@@ -2,7 +2,14 @@
 // HEADINGS //
 //////////////
 
-p, h1, h2, h3, h4, h5, h6, .card-title {
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.card-title {
   overflow-wrap: break-word;
   hyphens: auto;
 }
@@ -14,3 +21,12 @@ p, h1, h2, h3, h4, h5, h6, .card-title {
 [v-cloak] {
   display: none;
 }
+
+.v-card--reveal {
+  bottom: 0;
+  opacity: 1 !important;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  overflow-y: scroll;
+}
diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js
index 94f4131baf7181ea2a299c518f5fe942b850926d..ea18ea4dbc7cd71e42a0062da2ab80a987b11c51 100644
--- a/aleksis/core/assets/index.js
+++ b/aleksis/core/assets/index.js
@@ -1,4 +1,19 @@
-import '@mdi/font/css/materialdesignicons.css'
+import "@mdi/font/css/materialdesignicons.css";
 
-import "./util"
-import "./app"
+import "./util";
+import "./app";
+
+import CeleryProgress from "./components/celery_progress/CeleryProgress.vue";
+import About from "./components/about/About.vue";
+
+window.router.addRoute({
+  path: "/celery_progress/:taskId",
+  component: CeleryProgress,
+  props: true,
+  name: "core.celery_progress",
+});
+window.router.addRoute({
+  path: "/about",
+  component: About,
+  name: "core.about",
+});
diff --git a/aleksis/core/assets/messages.json b/aleksis/core/assets/messages.json
index 34c7dab8773a125d28657391f9e75d33d271e2b3..551b8e93f8175a097d917b906596fcd2be75d2a6 100644
--- a/aleksis/core/assets/messages.json
+++ b/aleksis/core/assets/messages.json
@@ -1,11 +1,40 @@
 {
   "en": {
-    "notifications":  {
+    "notifications": {
       "more_information": "More information",
       "no_notifications": "No notifications available yet."
     },
     "alerts": {
       "page_cached": "This page may contain outdated information since there is no internet connection."
+    },
+    "celery_progress": {
+      "progress_title": "Loading ...",
+      "error_message": "The operation couldn't be finished successfully.",
+      "success_message": "The operation has been finished successfully.",
+      "running_tasks": "1 running task | {number} running tasks"
+    },
+    "actions": {
+      "back": "Back",
+      "search": "Search"
+    },
+    "about": {
+      "about_aleksis": "About AlekSIS®",
+      "licenced_under": "Licenced under",
+      "licence_type": "Licence Type",
+      "free_software": "Free Software",
+      "open_source": "Open Source",
+      "proprietary": "Proprietary",
+      "licence_consists_of": "The licence consists of",
+      "about_aleksis_1": "This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and can be used by anyone.",
+      "about_aleksis_2": "AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V.",
+      "website_of_aleksis": "Website of AlekSIS",
+      "source_code": "Source Code",
+      "licence_information": "Licence Information",
+      "licence_information_1": "The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence information from third-party apps, if installed, refer to the respective components below. The licences are marked like this:",
+      "free_open_source_licence": "Free/Open Source Licence",
+      "other_licence": "Other Licence",
+      "full_licence_text": "Full Licence Text",
+      "more_information_eupl": "More information about the EUPL"
     }
   },
   "de": {
@@ -15,7 +44,35 @@
     },
     "alerts": {
       "page_cached": "Diese Seite enthält vielleicht veraltete Informationen, da es keine Internetverbindung gibt."
+    },
+    "celery_progress": {
+      "progress_title": "Wird geladen ...",
+      "error_message": "Der Vorgang konnte nicht erfolgreich beendet werden.",
+      "success_message": "Der Vorgang wurde erfolgreich beendet.",
+      "running_tasks": "1 laufende Aufgabe | {number} laufende Aufgaben"
+    },
+    "actions": {
+      "back": "Zurück",
+      "search": "Suchen"
+    },
+    "about": {
+      "about_aleksis": "Über AlekSIS®",
+      "licenced_under": "Lizensiert unter",
+      "licence_type": "Lizenztyp",
+      "free_software": "Freie Software",
+      "open_source": "Open Source",
+      "proprietary": "Proprietär",
+      "licence_consists_of": "Die Lizenz besteht aus",
+      "about_aleksis_1": "Diese Plattform wird mit AlekSIS®, einem webbasierten Schulinformationssystem (SIS), welches für die Verwaltung und/oder Veröffentlichung von Bildungseinrichtungen verwendet werden kann. AlekSIS ist freie Software und kann von jedem benutzt werden.",
+      "about_aleksis_2": " AlekSIS® ist eine eingetragene Wortmarke des Open-Source-Projektes AlekSIS, vertreten durch den Teckids e.V.",
+      "website_of_aleksis": "Website von AlekSIS",
+      "source_code": "Quellcode",
+      "licence_information": "Lizenzinformationen",
+      "licence_information_1": " Der Core und die offiziellen Apps von AlekSIS sind unter der EUPL, Version 1.2 oder später, lizenziert. Für Lizenzinformationen zu Apps von Drittanbietern, wenn installiert, siehe direkt bei der jeweiligen App weiter unten auf dieser Seite. Die Lizenzen sind wie folgt markiert:",
+      "free_open_source_licence": "Freie/Open Source Lizenz",
+      "other_licence": "Andere Lizenz",
+      "full_licence_text": "Kompletter Lizenztext",
+      "more_information_eupl": "Weitere Informationen über die EUPL"
     }
   }
 }
-
diff --git a/aleksis/core/assets/util.js b/aleksis/core/assets/util.js
index 1b5041b216cdae06868440b14b88f3d69acee914..a16f92bed1fc90e1930b89055278616419cf9c3b 100644
--- a/aleksis/core/assets/util.js
+++ b/aleksis/core/assets/util.js
@@ -1,153 +1,16 @@
-/*
-commented out to see if something breaks
-// Define maps between Python's strftime and Luxon's and Materialize's proprietary formats
-const pythonToMomentJs = {
-    "%a": "EEE",
-    "%A": "EEEE",
-    "%w": "E",
-    "%d": "dd",
-    "%b": "MMM",
-    "%B": "MMMM",
-    "%m": "MM",
-    "%y": "yy",
-    "%Y": "yyyy",
-    "%H": "HH",
-    "%I": "hh",
-    "%p": "a",
-    "%M": "mm",
-    "%s": "ss",
-    "%f": "SSSSSS",
-    "%z": "ZZZ",
-    "%Z": "z",
-    "%U": "WW",
-    "%j": "ooo",
-    "%W": "WW",
-    "%u": "E",
-    "%G": "kkkk",
-    "%V": "WW",
-};
-
-const pythonToMaterialize = {
-    "%d": "dd",
-    "%a": "ddd",
-    "%A": "dddd",
-    "%m": "mm",
-    "%b": "mmm",
-    "%B": "mmmm",
-    "%y": "yy",
-    "%Y": "yyyy",
-}
-
-function buildDateFormat(formatString, map) {
-    // Convert a Python strftime format string to another format string
-    for (const key in map) {
-        formatString = formatString.replace(key, map[key]);
-    }
-    return formatString;
-}
-
-function initDatePicker(sel) {
-    // Initialize datepicker [MAT]
-
-    // Get the date format from Django
-    const dateInputFormat = get_format('DATE_INPUT_FORMATS')[0]
-    const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs);
-    const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize);
-
-    const el = $(sel).datepicker({
-        format: outputFormat,
-        // Pull translations from Django helpers
-        i18n: {
-            months: calendarweek_i18n.month_names,
-            monthsShort: calendarweek_i18n.month_abbrs,
-            weekdays: calendarweek_i18n.day_names,
-            weekdaysShort: calendarweek_i18n.day_abbrs,
-            weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v),
-
-            // Buttons
-            today: gettext('Today'),
-            cancel: gettext('Cancel'),
-            done: gettext('OK'),
-        },
-
-        // Set monday as first day of week
-        firstDay: get_format('FIRST_DAY_OF_WEEK'),
-        autoClose: true,
-        yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100],
-    });
-
-    // Set initial values of datepickers
-    $(sel).each(function () {
-        const currentValue = $(this).val();
-        if (currentValue) {
-            const currentDate = luxon.DateTime.fromFormat(currentValue, inputFormat).toJSDate();
-            $(this).datepicker('setDate', currentDate);
-        }
-    });
-
-    return el;
-}
-
-function initTimePicker(sel) {
-    // Initialize timepicker [MAT]
-    return $(sel).timepicker({
-        twelveHour: false,
-        autoClose: true,
-        i18n: {
-            cancel: 'Abbrechen',
-            clear: 'Löschen',
-            done: 'OK'
-        },
-    });
-}
-*/
-
-
-$(document).ready(function () {
-
-    // If JS is activated, the language form will be auto-submitted
-    $('.language-field select').change(function () {
-        $(this).parents(".language-form").submit();
-    });
-
-    // If auto-submit is activated (see above), the language submit must not be visible
-    $(".language-submit-p").hide();
-
-    // Initalize print button
-    $("#print").click(function () {
-        window.print();
-    });
-
-    // Sync color picker
-    $(".jscolor").change(function () {
-        $("#" + $(this).data("preview")).css("color", $(this).val());
-    });
-
-    // Initialise auto-completion for search bar
-    window.autocomplete = new Autocomplete({minimum_length: 2});
-    window.autocomplete.setup();
-
-    // Initialize text collapsibles [MAT, own work]
-    $(".text-collapsible").addClass("closed").removeClass("opened");
-
-    $(".text-collapsible .open-icon").click(function (e) {
-        var el = $(e.target).parent();
-        el.addClass("opened").removeClass("closed");
-    });
-    $(".text-collapsible .close-icon").click(function (e) {
-        var el = $(e.target).parent();
-        el.addClass("closed").removeClass("opened");
-    });
-
-    // Initialize the service worker
-    if ('serviceWorker' in navigator) {
-        console.debug("Start registration of service worker.");
-        navigator.serviceWorker.register('/serviceworker.js', {
-            scope: '/'
-        }).then(function () {
-            console.debug("Service worker has been registered.");
-        }).catch(function () {
-            console.debug("Service worker registration has failed.")
-        });
-    }
+window.addEventListener("DOMContentLoaded", function () {
+  // Initialize the service worker
+  if ("serviceWorker" in navigator) {
+    console.debug("Start registration of service worker.");
+    navigator.serviceWorker
+      .register("/serviceworker.js", {
+        scope: "/",
+      })
+      .then(function () {
+        console.debug("Service worker has been registered.");
+      })
+      .catch(function () {
+        console.debug("Service worker registration has failed.");
+      });
+  }
 });
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index 05ebef2f4f3dd17124a4449ebe06b6622c1ad1be..92c120886e4a530cc566b8489cb09d3e38f5ba2e 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -611,6 +611,7 @@ class AccountRegisterForm(SignupForm, ExtensibleForm):
         request = kwargs.pop("request", None)
         super(AccountRegisterForm, self).__init__(*args, **kwargs)
 
+        person = None
         if request.session.get("account_verified_email"):
             email = request.session["account_verified_email"]
 
@@ -619,16 +620,27 @@ class AccountRegisterForm(SignupForm, ExtensibleForm):
             except (Person.DoesNotExist, Person.MultipleObjectsReturned):
                 raise SuspiciousOperation()
 
-            self.fields["email"].disabled = True
-            self.fields["email2"].disabled = True
+        elif request.session.get("invitation_code"):
+            try:
+                invitation = PersonInvitation.objects.get(
+                    key=request.session.get("invitation_code")
+                )
+            except PersonInvitation.DoesNotExist:
+                raise SuspiciousOperation()
 
-            if person:
-                available_fields = [field.name for field in Person._meta.get_fields()]
+            person = invitation.person
+
+        if person:
+            self.instance = person
+            available_fields = [field.name for field in Person._meta.get_fields()]
+            if person.email:
+                self.fields["email"].disabled = True
+                self.fields["email2"].disabled = True
                 self.fields["email2"].initial = person.email
-                for field in self.fields:
-                    if field in available_fields and getattr(person, field):
-                        self.fields[field].disabled = True
-                        self.fields[field].initial = getattr(person, field)
+            for field in self.fields:
+                if field in available_fields and getattr(person, field):
+                    self.fields[field].disabled = True
+                    self.fields[field].initial = getattr(person, field)
 
     def save(self, request):
         adapter = get_adapter(request)
@@ -639,8 +651,29 @@ class AccountRegisterForm(SignupForm, ExtensibleForm):
         for field in Person._meta.get_fields():
             if field.name in self.cleaned_data:
                 data[field.name] = self.cleaned_data[field.name]
-        if not Person.objects.filter(email=data["email"]):
-            _person, created = Person.objects.update_or_create(user=user, **data)
+        if self.instance:
+            person_qs = Person.objects.filter(pk=self.instance.pk)
+        else:
+            person_qs = Person.objects.filter(email=data["email"])
+            if not person_qs.exists():
+                if get_site_preferences()["account__auto_create_person"]:
+                    Person.objects.create(user=user, **data)
+        if person_qs.exists():
+            person = person_qs.first()
+            for field, value in data.items():
+                setattr(person, field, value)
+            person.user = user
+            person.save()
+        invitation_code = request.session.get("invitation_code")
+        if invitation_code:
+            from invitations.views import accept_invitation  # noqa
+
+            try:
+                invitation = PersonInvitation.objects.get(key=invitation_code)
+            except PersonInvitation.DoesNotExist:
+                raise SuspiciousOperation()
+
+            accept_invitation(invitation, request, user)
         self.custom_signup(request, user)
         setup_user_email(request, user, [])
         return user
diff --git a/aleksis/core/locale/ar/LC_MESSAGES/django.po b/aleksis/core/locale/ar/LC_MESSAGES/django.po
index df6153f7196d2535a19143cb11bfa43a0f831953..ce6e7cbbf7d64d4beb966c224212bbda2b32af2f 100644
--- a/aleksis/core/locale/ar/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/ar/LC_MESSAGES/django.po
@@ -2052,7 +2052,7 @@ msgstr ""
 #: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                Only admin and visitors from internal IPs can access thesite.\n"
+"                Only admin and visitors from internal IPs can access the site.\n"
 "              "
 msgstr ""
 
diff --git a/aleksis/core/locale/de_DE/LC_MESSAGES/django.po b/aleksis/core/locale/de_DE/LC_MESSAGES/django.po
index 7f2dcc874f2a27ea8cf2b0d8b7fd681e17e419f8..47480319a7caddf203af0a5823a09753dcb8814c 100644
--- a/aleksis/core/locale/de_DE/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/de_DE/LC_MESSAGES/django.po
@@ -2308,7 +2308,7 @@ msgstr "Wartungsmodus aktiviert"
 msgid ""
 "\n"
 "                Only admin and visitors from internal IPs can access "
-"thesite.\n"
+"the site.\n"
 "              "
 msgstr ""
 "\n"
diff --git a/aleksis/core/locale/fr/LC_MESSAGES/django.po b/aleksis/core/locale/fr/LC_MESSAGES/django.po
index b57a1588c4fb9a13d74200af11aa150a0f7b2c7d..8bc699a6898347499a588ac071ded207c301d4c3 100644
--- a/aleksis/core/locale/fr/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/fr/LC_MESSAGES/django.po
@@ -2130,7 +2130,7 @@ msgstr ""
 #: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                Only admin and visitors from internal IPs can access thesite.\n"
+"                Only admin and visitors from internal IPs can access the site.\n"
 "              "
 msgstr ""
 
diff --git a/aleksis/core/locale/la/LC_MESSAGES/django.po b/aleksis/core/locale/la/LC_MESSAGES/django.po
index b75d5e04731de1445f993badba1509a713f9e474..f75c1ff3027200fcc1f340efe6a34b17cb4e3529 100644
--- a/aleksis/core/locale/la/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/la/LC_MESSAGES/django.po
@@ -2238,7 +2238,7 @@ msgstr ""
 #: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                Only admin and visitors from internal IPs can access thesite.\n"
+"                Only admin and visitors from internal IPs can access the site.\n"
 "              "
 msgstr ""
 
diff --git a/aleksis/core/locale/nb_NO/LC_MESSAGES/django.po b/aleksis/core/locale/nb_NO/LC_MESSAGES/django.po
index c5d7f44167fc0d9207afcc857d56d3fecb23e834..a4ef0700a3b00bab188af924342dd605c6624b21 100644
--- a/aleksis/core/locale/nb_NO/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/nb_NO/LC_MESSAGES/django.po
@@ -2051,7 +2051,7 @@ msgstr ""
 #: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                Only admin and visitors from internal IPs can access thesite.\n"
+"                Only admin and visitors from internal IPs can access the site.\n"
 "              "
 msgstr ""
 
diff --git a/aleksis/core/locale/ru/LC_MESSAGES/django.po b/aleksis/core/locale/ru/LC_MESSAGES/django.po
index 722b473df4cad72ab8b7988d21325946bc6db3ee..378e7a1a676224c921dfb2f19c2d4cb5507c420f 100644
--- a/aleksis/core/locale/ru/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/ru/LC_MESSAGES/django.po
@@ -2248,7 +2248,7 @@ msgstr "Включен режим обслуживания"
 msgid ""
 "\n"
 "                Only admin and visitors from internal IPs can access "
-"thesite.\n"
+"the site.\n"
 "              "
 msgstr ""
 "\n"
diff --git a/aleksis/core/locale/tr_TR/LC_MESSAGES/django.po b/aleksis/core/locale/tr_TR/LC_MESSAGES/django.po
index c6ea389cc19f7b8da9cd26512cca741c38b41311..aa34c1560b1444e80ac7c9fb86995b007142332e 100644
--- a/aleksis/core/locale/tr_TR/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/tr_TR/LC_MESSAGES/django.po
@@ -2051,7 +2051,7 @@ msgstr ""
 #: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                Only admin and visitors from internal IPs can access thesite.\n"
+"                Only admin and visitors from internal IPs can access the site.\n"
 "              "
 msgstr ""
 
diff --git a/aleksis/core/locale/uk/LC_MESSAGES/django.po b/aleksis/core/locale/uk/LC_MESSAGES/django.po
index 58a52db0bbb5a0611820645658142a54de9b6e89..a5987d90d6bc2c3b1b54920ea0601d4f148eec23 100644
--- a/aleksis/core/locale/uk/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/uk/LC_MESSAGES/django.po
@@ -2282,7 +2282,7 @@ msgstr "Активований режим обслуговування"
 msgid ""
 "\n"
 "                Only admin and visitors from internal IPs can access "
-"thesite.\n"
+"the site.\n"
 "              "
 msgstr ""
 "\n"
diff --git a/aleksis/core/migrations/0042_pdffile_empty.py b/aleksis/core/migrations/0042_pdffile_empty.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8055132e11f2deba85ef4ecfa24840c51ce2081
--- /dev/null
+++ b/aleksis/core/migrations/0042_pdffile_empty.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.16 on 2022-11-03 11:36
+
+from django.db import migrations, models
+import django.utils.timezone
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0041_update_gender_choices'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='pdffile',
+            name='html_file',
+            field=models.FileField(blank=True, null=True, upload_to='pdfs/', verbose_name='Generated HTML file'),
+        ),
+    ]
diff --git a/aleksis/core/migrations/0043_task_assignment_meta.py b/aleksis/core/migrations/0043_task_assignment_meta.py
new file mode 100644
index 0000000000000000000000000000000000000000..cbacf70ba1e67ad0a1165aad4b6544fc0c7f0353
--- /dev/null
+++ b/aleksis/core/migrations/0043_task_assignment_meta.py
@@ -0,0 +1,62 @@
+# Generated by Django 3.2.15 on 2022-10-03 18:38
+
+from django.db import migrations, models
+import django.utils.timezone
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0042_pdffile_empty'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_icon',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Additional button icon'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_title',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Additional button title'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_url',
+            field=models.URLField(blank=True, verbose_name='Additional button URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='back_url',
+            field=models.URLField(blank=True, verbose_name='Back URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='error_message',
+            field=models.TextField(blank=True, verbose_name='Error message'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='success_message',
+            field=models.TextField(blank=True, verbose_name='Success message'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='progress_title',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Progress title'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='redirect_on_success_url',
+            field=models.URLField(blank=True, verbose_name='Redirect on success URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='title',
+            field=models.CharField(default='Data are processed', max_length=255, verbose_name='Title'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/aleksis/core/migrations/0044_task_assignment_result_fetched.py b/aleksis/core/migrations/0044_task_assignment_result_fetched.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4aa43a634bb6c702861a8800f7537b978735ee0
--- /dev/null
+++ b/aleksis/core/migrations/0044_task_assignment_result_fetched.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.16 on 2022-11-02 19:35
+
+import django.utils.timezone
+from django.db import migrations, models
+
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0043_task_assignment_meta"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="taskuserassignment",
+            name="result_fetched",
+            field=models.BooleanField(default=False, verbose_name="Result fetched"),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 42013a92de3688bc1dd2fd58dbd48083d2c6399d..e1f08e9c29c1116e53e54cded8980aebdb378504 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -127,7 +127,7 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
     """
 
     # Defines a material design icon associated with this type of model
-    icon_ = "radio_button_unchecked"
+    icon_ = "radiobox-blank"
 
     site = models.ForeignKey(
         Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 2f603ae0d2e2813b50f2024056cb460d71b4e484..f01086c8cbe8c786ab5d2497df77e730212c7c1f 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -31,6 +31,7 @@ import jsonstore
 from cachalot.api import cachalot_disabled
 from cache_memoize import cache_memoize
 from celery.result import AsyncResult
+from celery_progress.backend import Progress
 from ckeditor.fields import RichTextField
 from django_celery_results.models import TaskResult
 from django_cte import CTEQuerySet, With
@@ -176,7 +177,7 @@ class Person(ExtensibleModel):
             ),
         ]
 
-    icon_ = "person"
+    icon_ = "account-outline"
 
     SEX_CHOICES = [("f", _("female")), ("m", _("male")), ("x", _("other"))]
 
@@ -504,7 +505,7 @@ class Group(SchoolTermRelatedExtensibleModel):
             ),
         ]
 
-    icon_ = "group"
+    icon_ = "account-multiple-outline"
 
     name = models.CharField(verbose_name=_("Long name"), max_length=255)
     short_name = models.CharField(
@@ -1236,7 +1237,9 @@ class PDFFile(ExtensibleModel):
     expires_at = models.DateTimeField(
         verbose_name=_("File expires at"), default=_get_default_expiration
     )
-    html_file = models.FileField(upload_to="pdfs/", verbose_name=_("Generated HTML file"))
+    html_file = models.FileField(
+        upload_to="pdfs/", verbose_name=_("Generated HTML file"), blank=True, null=True
+    )
     file = models.FileField(
         upload_to="pdfs/", blank=True, null=True, verbose_name=_("Generated PDF file")
     )
@@ -1257,6 +1260,21 @@ class TaskUserAssignment(ExtensibleModel):
         get_user_model(), on_delete=models.CASCADE, verbose_name=_("Task user")
     )
 
+    title = models.CharField(max_length=255, verbose_name=_("Title"))
+    back_url = models.URLField(verbose_name=_("Back URL"), blank=True)
+    progress_title = models.CharField(max_length=255, verbose_name=_("Progress title"), blank=True)
+    error_message = models.TextField(verbose_name=_("Error message"), blank=True)
+    success_message = models.TextField(verbose_name=_("Success message"), blank=True)
+    redirect_on_success_url = models.URLField(verbose_name=_("Redirect on success URL"), blank=True)
+    additional_button_title = models.CharField(
+        max_length=255, verbose_name=_("Additional button title"), blank=True
+    )
+    additional_button_url = models.URLField(verbose_name=_("Additional button URL"), blank=True)
+    additional_button_icon = models.CharField(
+        max_length=255, verbose_name=_("Additional button icon"), blank=True
+    )
+    result_fetched = models.BooleanField(default=False, verbose_name=_("Result fetched"))
+
     @classmethod
     def create_for_task_id(cls, task_id: str, user: "User") -> "TaskUserAssignment":
         # Use get_or_create to ensure the TaskResult exists
@@ -1265,6 +1283,45 @@ class TaskUserAssignment(ExtensibleModel):
             result, __ = TaskResult.objects.get_or_create(task_id=task_id)
         return cls.objects.create(task_result=result, user=user)
 
+    def get_progress(self) -> dict[str, any]:
+        """Get progress information for this task."""
+        progress = Progress(AsyncResult(self.task_result.task_id))
+        return progress.get_info()
+
+    def get_progress_with_meta(self) -> dict[str, any]:
+        """Get progress information for this task."""
+        progress = self.get_progress()
+        progress["meta"] = self
+        return progress
+
+    def create_notification(self) -> Optional[Notification]:
+        """Create a notification for this task."""
+        progress = self.get_progress()
+        if progress["state"] == "SUCCESS":
+            title = _("Background task completed successfully")
+            description = _("The background task '{}' has been completed successfully.").format(
+                self.title
+            )
+
+        elif progress["state"] == "FAILURE":
+            title = _("Background task failed")
+            description = _("The background task '{}' has failed.").format(self.title)
+        else:
+            # Task not yet finished
+            return
+
+        link = reverse("task_status", args=[self.task_result.task_id])
+
+        notification = Notification(
+            sender=_("Background task"),
+            recipient=self.user.person,
+            title=title,
+            description=description,
+            link=link,
+        )
+        notification.save()
+        return notification
+
     class Meta:
         verbose_name = _("Task user assignment")
         verbose_name_plural = _("Task user assignments")
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index e12744ff6df91c72f8ec541b8f0c0672d2ddf273..c5f2bc4d498064d687392b7e63057de50b5c1136 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -11,6 +11,7 @@ from .util.predicates import (
     is_current_person,
     is_group_owner,
     is_notification_recipient,
+    is_own_celery_task,
     is_site_preference_set,
 )
 
@@ -374,3 +375,6 @@ rules.add_perm("core.edit_ical_rule", edit_ical_predicate)
 
 delete_ical_predicate = edit_ical_predicate
 rules.add_perm("core.delete_ical_rule", delete_ical_predicate)
+
+view_progress_predicate = has_person & is_own_celery_task
+rules.add_perm("core.view_progress_rule", view_progress_predicate)
diff --git a/aleksis/core/schema.py b/aleksis/core/schema.py
deleted file mode 100644
index cb6325c0640557314ae299a1d81719d910468391..0000000000000000000000000000000000000000
--- a/aleksis/core/schema.py
+++ /dev/null
@@ -1,143 +0,0 @@
-from django.conf import settings
-from django.utils import translation
-
-import graphene
-from graphene import ObjectType
-from graphene_django import DjangoObjectType
-from graphene_django.forms.mutation import DjangoModelFormMutation
-
-from .forms import PersonForm
-from .models import Group, Notification, Person
-from .util.core_helpers import get_app_module, get_app_packages, has_person
-from .util.frontend_helpers import get_language_cookie
-
-
-class NotificationType(DjangoObjectType):
-    class Meta:
-        model = Notification
-
-
-class PersonType(DjangoObjectType):
-    class Meta:
-        model = Person
-
-    full_name = graphene.Field(graphene.String)
-
-    def resolve_full_name(root: Person, info, **kwargs):
-        return root.full_name
-
-
-class GroupType(DjangoObjectType):
-    class Meta:
-        model = Group
-
-
-class LanguageType(ObjectType):
-    code = graphene.String(required=True)
-    name = graphene.String(required=True)
-    name_local = graphene.String(required=True)
-    name_translated = graphene.String(required=True)
-    bidi = graphene.Boolean(required=True)
-    cookie = graphene.String(required=True)
-
-
-class SystemPropertiesType(graphene.ObjectType):
-    current_language = graphene.String(required=True)
-    available_languages = graphene.List(LanguageType)
-
-    def resolve_current_language(parent, info, **kwargs):
-        return info.context.LANGUAGE_CODE
-
-    def resolve_available_languages(parent, info, **kwargs):
-        return [
-            translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
-            for code, name in settings.LANGUAGES
-        ]
-
-
-class PersonMutation(DjangoModelFormMutation):
-    person = graphene.Field(PersonType)
-
-    class Meta:
-        form_class = PersonForm
-
-
-class MarkNotificationReadMutation(graphene.Mutation):
-    class Arguments:
-        id = graphene.ID()  # noqa
-
-    notification = graphene.Field(NotificationType)
-
-    @classmethod
-    def mutate(cls, root, info, id):  # noqa
-        notification = Notification.objects.get(pk=id)
-        # FIXME permissions
-        notification.read = True
-        notification.save()
-
-        return notification
-
-
-class Query(graphene.ObjectType):
-    ping = graphene.String(default_value="pong")
-
-    notifications = graphene.List(NotificationType)
-
-    persons = graphene.List(PersonType)
-    person_by_id = graphene.Field(PersonType, id=graphene.ID())
-    who_am_i = graphene.Field(PersonType)
-
-    system_properties = graphene.Field(SystemPropertiesType)
-
-    def resolve_notifications(root, info, **kwargs):
-        # FIXME do permission stuff
-        return Notification.objects.all()
-
-    def resolve_persons(root, info, **kwargs):
-        # FIXME do permission stuff
-        return Person.objects.all()
-
-    def resolve_person_by_id(root, info, id):  # noqa
-        return Person.objects.get(pk=id)
-
-    def resolve_who_am_i(root, info, **kwargs):
-        if has_person(info.context.user):
-            return info.context.user.person
-        else:
-            return None
-
-    def resolve_system_properties(root, info, **kwargs):
-        return True
-
-
-class Mutation(graphene.ObjectType):
-    update_person = PersonMutation.Field()
-
-    mark_notification_read = MarkNotificationReadMutation.Field()
-
-
-def build_global_schema():
-    """Build global GraphQL schema from all apps."""
-    query_bases = [Query]
-    mutation_bases = [Mutation]
-
-    for app in get_app_packages():
-        schema_mod = get_app_module(app, "schema")
-        if not schema_mod:
-            # The app does not define a schema
-            continue
-
-        if AppQuery := getattr(schema_mod, "Query", None):
-            query_bases.append(AppQuery)
-        if AppMutation := getattr(schema_mod, "Mutation", None):
-            mutation_bases.append(AppMutation)
-
-    # Define classes using all query/mutation classes as mixins
-    #  cf. https://docs.graphene-python.org/projects/django/en/latest/schema/#adding-to-the-schema
-    GlobalQuery = type("GlobalQuery", tuple(query_bases), {})
-    GlobalMutation = type("GlobalMutation", tuple(mutation_bases), {})
-
-    return graphene.Schema(query=GlobalQuery, mutation=GlobalMutation)
-
-
-schema = build_global_schema()
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f5a2fa8e0723e883378541185e11d0649b18898
--- /dev/null
+++ b/aleksis/core/schema/__init__.py
@@ -0,0 +1,121 @@
+from django.apps import apps
+
+import graphene
+from haystack.inputs import AutoQuery
+from haystack.query import SearchQuerySet
+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 .group import GroupType  # noqa
+from .installed_apps import AppType
+from .notification import MarkNotificationReadMutation, NotificationType
+from .person import PersonMutation, PersonType
+from .search import SearchResultType
+from .system_properties import SystemPropertiesType
+
+
+class Query(graphene.ObjectType):
+    ping = graphene.String(default_value="pong")
+
+    notifications = graphene.List(NotificationType)
+
+    persons = graphene.List(PersonType)
+    person_by_id = graphene.Field(PersonType, id=graphene.ID())
+    who_am_i = graphene.Field(PersonType)
+
+    system_properties = graphene.Field(SystemPropertiesType)
+    installed_apps = graphene.List(AppType)
+
+    celery_progress_by_task_id = graphene.Field(CeleryProgressType, task_id=graphene.String())
+    celery_progress_by_user = graphene.List(CeleryProgressType)
+
+    search_snippets = graphene.List(
+        SearchResultType, query=graphene.String(), limit=graphene.Int(required=False)
+    )
+
+    def resolve_notifications(root, info, **kwargs):
+        # FIXME do permission stuff
+        return Notification.objects.all()
+
+    def resolve_persons(root, info, **kwargs):
+        # FIXME do permission stuff
+        return Person.objects.all()
+
+    def resolve_person_by_id(root, info, id):  # noqa
+        return Person.objects.get(pk=id)
+
+    def resolve_who_am_i(root, info, **kwargs):
+        if has_person(info.context.user):
+            return info.context.user.person
+        else:
+            return None
+
+    def resolve_system_properties(root, info, **kwargs):
+        return True
+
+    def resolve_installed_apps(root, info, **kwargs):
+        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)
+
+        if not info.context.user.has_perm("core.view_progress_rule", task):
+            return None
+        progress = task.get_progress_with_meta()
+        return progress
+
+    def resolve_celery_progress_by_user(root, info, **kwargs):
+        tasks = TaskUserAssignment.objects.filter(user=info.context.user)
+        return [
+            task.get_progress_with_meta()
+            for task in tasks
+            if task.get_progress_with_meta()["complete"] is False
+        ]
+
+    def resolve_search_snippets(root, info, query, limit=-1, **kwargs):
+        indexed_models = UnifiedIndex().get_indexed_models()
+        allowed_object_ids = get_allowed_object_ids(info.context.user, indexed_models)
+        results = SearchQuerySet().filter(id__in=allowed_object_ids).filter(text=AutoQuery(query))
+
+        if limit < 0:
+            return results
+
+        return results[:limit]
+
+
+class Mutation(graphene.ObjectType):
+    update_person = PersonMutation.Field()
+
+    mark_notification_read = MarkNotificationReadMutation.Field()
+
+    celery_progress_fetched = CeleryProgressFetchedMutation.Field()
+
+
+def build_global_schema():
+    """Build global GraphQL schema from all apps."""
+    query_bases = [Query]
+    mutation_bases = [Mutation]
+
+    for app in get_app_packages():
+        schema_mod = get_app_module(app, "schema")
+        if not schema_mod:
+            # The app does not define a schema
+            continue
+
+        if AppQuery := getattr(schema_mod, "Query", None):
+            query_bases.append(AppQuery)
+        if AppMutation := getattr(schema_mod, "Mutation", None):
+            mutation_bases.append(AppMutation)
+
+    # Define classes using all query/mutation classes as mixins
+    #  cf. https://docs.graphene-python.org/projects/django/en/latest/schema/#adding-to-the-schema
+    GlobalQuery = type("GlobalQuery", tuple(query_bases), {})
+    GlobalMutation = type("GlobalMutation", tuple(mutation_bases), {})
+
+    return graphene.Schema(query=GlobalQuery, mutation=GlobalMutation)
+
+
+schema = build_global_schema()
diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py
new file mode 100644
index 0000000000000000000000000000000000000000..941d18f1d5e79db17a549d30dbb90f0c42310a25
--- /dev/null
+++ b/aleksis/core/schema/celery_progress.py
@@ -0,0 +1,94 @@
+from django.contrib.messages.constants import DEFAULT_TAGS
+
+import graphene
+from graphene import ObjectType
+from graphene_django import DjangoObjectType
+
+from ..models import TaskUserAssignment
+
+
+class CeleryProgressMessage(ObjectType):
+    message = graphene.String(required=True)
+    level = graphene.Int(required=True)
+    tag = graphene.String(required=True)
+
+    def resolve_message(root, info, **kwargs):
+        return root[1]
+
+    def resolve_level(root, info, **kwargs):
+        return root[0]
+
+    def resolve_tag(root, info, **kwargs):
+        return DEFAULT_TAGS.get(root[0], "info")
+
+
+class CeleryProgressAdditionalButtonType(ObjectType):
+    title = graphene.String(required=True)
+    url = graphene.String(required=True)
+    icon = graphene.String()
+
+
+class CeleryProgressMetaType(DjangoObjectType):
+    additional_button = graphene.Field(CeleryProgressAdditionalButtonType, required=False)
+    task_id = graphene.String(required=True)
+
+    def resolve_task_id(root, info, **kwargs):
+        return root.task_result.task_id
+
+    class Meta:
+        model = TaskUserAssignment
+        fields = (
+            "title",
+            "back_url",
+            "progress_title",
+            "error_message",
+            "success_message",
+            "redirect_on_success_url",
+            "additional_button",
+        )
+
+    def resolve_additional_button(root, info, **kwargs):
+        if not root.additional_button_title or not root.additional_button_url:
+            return None
+        return {
+            "title": root.additional_button_title,
+            "url": root.additional_button_url,
+            "icon": root.additional_button_icon,
+        }
+
+
+class CeleryProgressProgressType(ObjectType):
+    current = graphene.Int()
+    total = graphene.Int()
+    percent = graphene.Float()
+
+
+class CeleryProgressType(graphene.ObjectType):
+    state = graphene.String()
+    complete = graphene.Boolean()
+    success = graphene.Boolean()
+    progress = graphene.Field(CeleryProgressProgressType)
+    messages = graphene.List(CeleryProgressMessage)
+    meta = graphene.Field(CeleryProgressMetaType)
+
+    def resolve_messages(root, info, **kwargs):  # noqa
+        if root["complete"] and isinstance(root["result"], list):
+            return root["result"]
+        return root["progress"].get("messages", [])
+
+
+class CeleryProgressFetchedMutation(graphene.Mutation):
+    class Arguments:
+        task_id = graphene.String(required=True)
+
+    celery_progress = graphene.Field(CeleryProgressType)
+
+    def mutate(root, info, task_id, **kwargs):
+        task = TaskUserAssignment.objects.filter(task_result__task_id=task_id)
+
+        if not info.context.user.has_perm("core.view_progress_rule", task):
+            return None
+        task.result_fetched = True
+        task.save()
+        progress = task.get_progress_with_meta()
+        return CeleryProgressFetchedMutation(celery_progress=progress)
diff --git a/aleksis/core/schema/group.py b/aleksis/core/schema/group.py
new file mode 100644
index 0000000000000000000000000000000000000000..327daff3dd09d54053b683ba4cefb71da8ba6e5b
--- /dev/null
+++ b/aleksis/core/schema/group.py
@@ -0,0 +1,8 @@
+from graphene_django import DjangoObjectType
+
+from ..models import Group
+
+
+class GroupType(DjangoObjectType):
+    class Meta:
+        model = Group
diff --git a/aleksis/core/schema/installed_apps.py b/aleksis/core/schema/installed_apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..879575466ae2f06795c75c9b928fdc7f8cdb4499
--- /dev/null
+++ b/aleksis/core/schema/installed_apps.py
@@ -0,0 +1,58 @@
+import graphene
+from graphene import ObjectType
+
+
+class AppURLType(ObjectType):
+    name = graphene.String(required=True)
+    url = graphene.String(required=True)
+
+
+class CopyrightType(ObjectType):
+    years = graphene.String(required=True)
+    name = graphene.String(required=True)
+    email = graphene.String(required=True)
+
+
+class LicenceFlagsType(ObjectType):
+    isFsfLibre = graphene.Boolean(required=True)
+    isOsiApproved = graphene.Boolean(required=True)
+
+
+class SubLicenceType(ObjectType):
+    isDeprecatedLicenseId = graphene.Boolean(default_value=False)
+    isFsfLibre = graphene.Boolean(default_value=False)
+    isOsiApproved = graphene.Boolean(default_value=False)
+    licenseId = graphene.String(required=True)
+    name = graphene.String(required=True)
+    referenceNumber = graphene.Int(default_value=-1)
+    url = graphene.String()
+
+
+class LicenceType(ObjectType):
+    verbose_name = graphene.String(required=True)
+    flags = graphene.Field(LicenceFlagsType, required=True)
+    licences = graphene.List(SubLicenceType)
+
+
+class AppType(ObjectType):
+    copyrights = graphene.List(CopyrightType)
+    licence = graphene.Field(LicenceType)
+    name = graphene.String(required=True)
+    verbose_name = graphene.String(required=True)
+    version = graphene.String()
+    urls = graphene.List(AppURLType)
+
+    def resolve_verbose_name(root, info, **kwargs):
+        return root.get_name()
+
+    def resolve_version(root, info, **kwargs):
+        return root.get_version()
+
+    def resolve_licence(root, info, **kwargs):
+        return root.get_licence_dict()
+
+    def resolve_urls(root, info, **kwargs):
+        return root.get_urls_dict()
+
+    def resolve_copyrights(root, info, **kwargs):
+        return root.get_copyright_dicts()
diff --git a/aleksis/core/schema/notification.py b/aleksis/core/schema/notification.py
new file mode 100644
index 0000000000000000000000000000000000000000..114f92b32d9658208013144fe2d7c2d2b0157595
--- /dev/null
+++ b/aleksis/core/schema/notification.py
@@ -0,0 +1,25 @@
+import graphene
+from graphene_django import DjangoObjectType
+
+from ..models import Notification
+
+
+class NotificationType(DjangoObjectType):
+    class Meta:
+        model = Notification
+
+
+class MarkNotificationReadMutation(graphene.Mutation):
+    class Arguments:
+        id = graphene.ID()  # noqa
+
+    notification = graphene.Field(NotificationType)
+
+    @classmethod
+    def mutate(cls, root, info, id):  # noqa
+        notification = Notification.objects.get(pk=id)
+        # FIXME permissions
+        notification.read = True
+        notification.save()
+
+        return notification
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3a22ce6779c7974cb61e5efeb454c1203fd8c73
--- /dev/null
+++ b/aleksis/core/schema/person.py
@@ -0,0 +1,23 @@
+import graphene
+from graphene_django import DjangoObjectType
+from graphene_django.forms.mutation import DjangoModelFormMutation
+
+from ..forms import PersonForm
+from ..models import Person
+
+
+class PersonType(DjangoObjectType):
+    class Meta:
+        model = Person
+
+    full_name = graphene.Field(graphene.String)
+
+    def resolve_full_name(root: Person, info, **kwargs):
+        return root.full_name
+
+
+class PersonMutation(DjangoModelFormMutation):
+    person = graphene.Field(PersonType)
+
+    class Meta:
+        form_class = PersonForm
diff --git a/aleksis/core/schema/search.py b/aleksis/core/schema/search.py
new file mode 100644
index 0000000000000000000000000000000000000000..290ac8818730e42e540c752362f5e2dcf5ea2840
--- /dev/null
+++ b/aleksis/core/schema/search.py
@@ -0,0 +1,32 @@
+import graphene
+
+
+class SearchModelType(graphene.ObjectType):
+    absolute_url = graphene.String()
+    name = graphene.String()
+    icon = graphene.String()
+
+    def resolve_absolute_url(root, info, **kwargs):
+        if hasattr(root, "get_absolute_url"):
+            return root.get_absolute_url()
+        else:
+            return "#!"
+
+    def resolve_name(root, info, **kwargs):
+        return str(root)
+
+    def resolve_icon(root, info, **kwargs):
+        return getattr(root, "icon_", "")
+
+
+class SearchResultType(graphene.ObjectType):
+    app_label = graphene.String()
+    model_name = graphene.String()
+    score = graphene.Int()
+    obj = graphene.Field(SearchModelType)
+    verbose_name = graphene.String()
+    verbose_name_plural = graphene.String()
+    text = graphene.String()
+
+    def resolve_obj(root, info, **kwargs):  # noqa
+        return root.object
diff --git a/aleksis/core/schema/system_properties.py b/aleksis/core/schema/system_properties.py
new file mode 100644
index 0000000000000000000000000000000000000000..3958df762b886d5b0349b838db09e46f11ca0ffc
--- /dev/null
+++ b/aleksis/core/schema/system_properties.py
@@ -0,0 +1,29 @@
+from django.conf import settings
+from django.utils import translation
+
+import graphene
+
+from ..util.frontend_helpers import get_language_cookie
+
+
+class LanguageType(graphene.ObjectType):
+    code = graphene.String(required=True)
+    name = graphene.String(required=True)
+    name_local = graphene.String(required=True)
+    name_translated = graphene.String(required=True)
+    bidi = graphene.Boolean(required=True)
+    cookie = graphene.String(required=True)
+
+
+class SystemPropertiesType(graphene.ObjectType):
+    current_language = graphene.String(required=True)
+    available_languages = graphene.List(LanguageType)
+
+    def resolve_current_language(parent, info, **kwargs):
+        return info.context.LANGUAGE_CODE
+
+    def resolve_available_languages(parent, info, **kwargs):
+        return [
+            translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
+            for code, name in settings.LANGUAGES
+        ]
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 015c15d62448ecbd354a0d14680fa5a601054f18..d901f6261471f4450745da248e550ba780792a28 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -378,6 +378,7 @@ OAUTH2_PROVIDER = {
     "OAUTH2_VALIDATOR_CLASS": "aleksis.core.util.auth_helpers.CustomOAuth2Validator",
     "OIDC_ENABLED": True,
     "REFRESH_TOKEN_EXPIRE_SECONDS": _settings.get("oauth2.token_expiry", 86400),
+    "PKCE_REQUIRED": False,
 }
 OAUTH2_PROVIDER_APPLICATION_MODEL = "core.OAuthApplication"
 OAUTH2_PROVIDER_GRANT_MODEL = "core.OAuthGrant"
@@ -553,7 +554,6 @@ YARN_INSTALLED_APPS = [
     "jquery@^3.6.0",
     "@materializecss/materialize@~1.0.0",
     "material-design-icons-iconfont@^6.6.0",
-    "select2@^4.1.0-rc.0",
     "select2-materialize@^0.1.8",
     "paper-css@^0.4.1",
     "jquery-sortablejs@^1.0.1",
@@ -581,6 +581,14 @@ YARN_INSTALLED_APPS = [
     "webpack-bundle-tracker@^1.6.0",
     "webpack-cli@^4.10.0",
     "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",
 ]
 
 merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
@@ -602,11 +610,6 @@ STATICFILES_DIRS = (
     JS_ROOT,
 )
 
-
-SELECT2_CSS = JS_URL + "/select2/dist/css/select2.min.css"
-SELECT2_JS = JS_URL + "/select2/dist/js/select2.min.js"
-SELECT2_I18N_PATH = JS_URL + "/select2/dist/js/i18n"
-
 ANY_JS = {
     "materialize": {"js_url": JS_URL + "/@materializecss/materialize/dist/js/materialize.min.js"},
     "jQuery": {"js_url": JS_URL + "/jquery/dist/jquery.min.js"},
@@ -742,6 +745,7 @@ CELERY_BROKER_URL = _settings.get("celery.broker", REDIS_URL)
 CELERY_RESULT_BACKEND = "django-db"
 CELERY_CACHE_BACKEND = "django-cache"
 CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
+CELERY_RESULT_EXTENDED = True
 
 if _settings.get("celery.email", False):
     EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
diff --git a/aleksis/core/static/js/copy_button.js b/aleksis/core/static/js/copy_button.js
index 554f6230e4f9c8b9bb012875e31da25c4f61c2c6..c7f53e61bf5fa91a13fd24be0b01832755e955d4 100644
--- a/aleksis/core/static/js/copy_button.js
+++ b/aleksis/core/static/js/copy_button.js
@@ -1,16 +1,16 @@
 $(".copy-button").click((e) => {
-    const target = $(e.currentTarget);
-    const input = $("#" + target.data("target"));
-    const copy_icon = target.children(".copy-icon-copy").first();
-    const check_icon = target.children(".copy-icon-success").first();
+  const target = $(e.currentTarget);
+  const input = $("#" + target.data("target"));
+  const copy_icon = target.children(".copy-icon-copy").first();
+  const check_icon = target.children(".copy-icon-success").first();
 
-    console.log("Copying to clipboard");
-    navigator.clipboard.writeText(input.val()).then(r => {
-        check_icon.show();
-        copy_icon.hide();
-        setTimeout(() => {
-            check_icon.hide();
-            copy_icon.show();
-        }, 1000);
-    });
+  console.log("Copying to clipboard");
+  navigator.clipboard.writeText(input.val()).then((r) => {
+    check_icon.show();
+    copy_icon.hide();
+    setTimeout(() => {
+      check_icon.hide();
+      copy_icon.show();
+    }, 1000);
+  });
 });
diff --git a/aleksis/core/static/js/edit_dashboard.js b/aleksis/core/static/js/edit_dashboard.js
index 0cc90de60305497a682d219b121e77550d273d1d..b6e441191c118b4956e3f54cab919d9caa8fde25 100644
--- a/aleksis/core/static/js/edit_dashboard.js
+++ b/aleksis/core/static/js/edit_dashboard.js
@@ -1,22 +1,22 @@
 function refreshOrder() {
-    $(".order-input").val(0);
-    $("#widgets > .col").each(function (index) {
-        const order = (index + 1) * 10;
-        let pk = $(this).attr("data-pk");
-        let sel = $("#order-form input[value=" + pk + "].pk-input").next();
-        sel.val(order);
-    })
+  $(".order-input").val(0);
+  $("#widgets > .col").each(function (index) {
+    const order = (index + 1) * 10;
+    let pk = $(this).attr("data-pk");
+    let sel = $("#order-form input[value=" + pk + "].pk-input").next();
+    sel.val(order);
+  });
 }
 
 $(document).ready(function () {
-    $('#not-used-widgets').sortable({
-        group: 'widgets',
-        animation: 150,
-        onEnd: refreshOrder
-    });
-    $('#widgets').sortable({
-        group: 'widgets',
-        animation: 150,
-        onEnd: refreshOrder
-    });
+  $("#not-used-widgets").sortable({
+    group: "widgets",
+    animation: 150,
+    onEnd: refreshOrder,
+  });
+  $("#widgets").sortable({
+    group: "widgets",
+    animation: 150,
+    onEnd: refreshOrder,
+  });
 });
diff --git a/aleksis/core/static/js/helper.js b/aleksis/core/static/js/helper.js
index 844496346e451a89814ae90194db57fb67a72434..48dab95651703837a071ee39bcc3f9682d071c9b 100644
--- a/aleksis/core/static/js/helper.js
+++ b/aleksis/core/static/js/helper.js
@@ -1,30 +1,37 @@
 function formatDate(date) {
-    return date.getDate() + "." + (date.getMonth() + 1) + "." + date.getFullYear();
+  return (
+    date.getDate() + "." + (date.getMonth() + 1) + "." + date.getFullYear()
+  );
 }
 
-
 function addZeros(i) {
-    if (i < 10) {
-        return "0" + i;
-    } else {
-        return "" + i;
-    }
+  if (i < 10) {
+    return "0" + i;
+  } else {
+    return "" + i;
+  }
 }
 
 function formatDateForDjango(date) {
-    return "" + date.getFullYear() + "/" + addZeros(date.getMonth() + 1) + "/" + addZeros(date.getDate()) + "/";
-
+  return (
+    "" +
+    date.getFullYear() +
+    "/" +
+    addZeros(date.getMonth() + 1) +
+    "/" +
+    addZeros(date.getDate()) +
+    "/"
+  );
 }
 
 function getNow() {
-    return new Date();
+  return new Date();
 }
 
 function getNowFormatted() {
-    return formatDate(getNow());
+  return formatDate(getNow());
 }
 
 function getJSONScript(elementId) {
-    return JSON.parse(document.getElementById(elementId).textContent);
+  return JSON.parse(document.getElementById(elementId).textContent);
 }
-
diff --git a/aleksis/core/static/js/include_ajax_live.js b/aleksis/core/static/js/include_ajax_live.js
index 3a4794bad9881ebe502ea7786b8c0d2a740e65f0..0d23769c1c68114c638a8daf7217b2bc72d8948e 100644
--- a/aleksis/core/static/js/include_ajax_live.js
+++ b/aleksis/core/static/js/include_ajax_live.js
@@ -14,7 +14,7 @@ const setAsyncInterval = (cb, interval) => {
     runAsyncInterval(cb, interval, intervalIndex);
     return intervalIndex;
   } else {
-    throw new Error('Callback must be a function');
+    throw new Error("Callback must be a function");
   }
 };
 
@@ -25,11 +25,11 @@ const clearAsyncInterval = (intervalIndex) => {
 };
 
 let live_load_interval = setAsyncInterval(async () => {
-  console.log('fetching new data');
+  console.log("fetching new data");
   const promise = new Promise((resolve) => {
-    $('#live_load').load(window.location.pathname + " #live_load");
+    $("#live_load").load(window.location.pathname + " #live_load");
     resolve(1);
   });
   await promise;
-  console.log('data fetched successfully');
+  console.log("data fetched successfully");
 }, 15000);
diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js
index b735e2bb53d4a3c6a8f5b1edc9a9bb307214e1b4..8562b31f01d225e3f308117f78aa90ad3192f63b 100644
--- a/aleksis/core/static/js/main.js
+++ b/aleksis/core/static/js/main.js
@@ -1,197 +1,212 @@
 // Define maps between Python's strftime and Luxon's and Materialize's proprietary formats
 const pythonToMomentJs = {
-    "%a": "EEE",
-    "%A": "EEEE",
-    "%w": "E",
-    "%d": "dd",
-    "%b": "MMM",
-    "%B": "MMMM",
-    "%m": "MM",
-    "%y": "yy",
-    "%Y": "yyyy",
-    "%H": "HH",
-    "%I": "hh",
-    "%p": "a",
-    "%M": "mm",
-    "%s": "ss",
-    "%f": "SSSSSS",
-    "%z": "ZZZ",
-    "%Z": "z",
-    "%U": "WW",
-    "%j": "ooo",
-    "%W": "WW",
-    "%u": "E",
-    "%G": "kkkk",
-    "%V": "WW",
+  "%a": "EEE",
+  "%A": "EEEE",
+  "%w": "E",
+  "%d": "dd",
+  "%b": "MMM",
+  "%B": "MMMM",
+  "%m": "MM",
+  "%y": "yy",
+  "%Y": "yyyy",
+  "%H": "HH",
+  "%I": "hh",
+  "%p": "a",
+  "%M": "mm",
+  "%s": "ss",
+  "%f": "SSSSSS",
+  "%z": "ZZZ",
+  "%Z": "z",
+  "%U": "WW",
+  "%j": "ooo",
+  "%W": "WW",
+  "%u": "E",
+  "%G": "kkkk",
+  "%V": "WW",
 };
 
 const pythonToMaterialize = {
-    "%d": "dd",
-    "%a": "ddd",
-    "%A": "dddd",
-    "%m": "mm",
-    "%b": "mmm",
-    "%B": "mmmm",
-    "%y": "yy",
-    "%Y": "yyyy",
-}
+  "%d": "dd",
+  "%a": "ddd",
+  "%A": "dddd",
+  "%m": "mm",
+  "%b": "mmm",
+  "%B": "mmmm",
+  "%y": "yy",
+  "%Y": "yyyy",
+};
 
 function buildDateFormat(formatString, map) {
-    // Convert a Python strftime format string to another format string
-    for (const key in map) {
-        formatString = formatString.replace(key, map[key]);
-    }
-    return formatString;
+  // Convert a Python strftime format string to another format string
+  for (const key in map) {
+    formatString = formatString.replace(key, map[key]);
+  }
+  return formatString;
 }
 
 function initDatePicker(sel) {
-    // Initialize datepicker [MAT]
-
-    // Get the date format from Django
-    const dateInputFormat = get_format('DATE_INPUT_FORMATS')[0]
-    const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs);
-    const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize);
-
-    const el = $(sel).datepicker({
-        format: outputFormat,
-        // Pull translations from Django helpers
-        i18n: {
-            months: calendarweek_i18n.month_names,
-            monthsShort: calendarweek_i18n.month_abbrs,
-            weekdays: calendarweek_i18n.day_names,
-            weekdaysShort: calendarweek_i18n.day_abbrs,
-            weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v),
-
-            // Buttons
-            today: gettext('Today'),
-            cancel: gettext('Cancel'),
-            done: gettext('OK'),
-        },
-
-        // Set monday as first day of week
-        firstDay: get_format('FIRST_DAY_OF_WEEK'),
-        autoClose: true,
-        yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100],
-    });
-
-    // Set initial values of datepickers
-    $(sel).each(function () {
-        const currentValue = $(this).val();
-        if (currentValue) {
-            const currentDate = luxon.DateTime.fromFormat(currentValue, inputFormat).toJSDate();
-            $(this).datepicker('setDate', currentDate);
-        }
-    });
-
-    return el;
+  // Initialize datepicker [MAT]
+
+  // Get the date format from Django
+  const dateInputFormat = get_format("DATE_INPUT_FORMATS")[0];
+  const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs);
+  const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize);
+
+  const el = $(sel).datepicker({
+    format: outputFormat,
+    // Pull translations from Django helpers
+    i18n: {
+      months: calendarweek_i18n.month_names,
+      monthsShort: calendarweek_i18n.month_abbrs,
+      weekdays: calendarweek_i18n.day_names,
+      weekdaysShort: calendarweek_i18n.day_abbrs,
+      weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v),
+
+      // Buttons
+      today: gettext("Today"),
+      cancel: gettext("Cancel"),
+      done: gettext("OK"),
+    },
+
+    // Set monday as first day of week
+    firstDay: get_format("FIRST_DAY_OF_WEEK"),
+    autoClose: true,
+    yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100],
+  });
+
+  // Set initial values of datepickers
+  $(sel).each(function () {
+    const currentValue = $(this).val();
+    if (currentValue) {
+      const currentDate = luxon.DateTime.fromFormat(
+        currentValue,
+        inputFormat
+      ).toJSDate();
+      $(this).datepicker("setDate", currentDate);
+    }
+  });
+
+  return el;
 }
 
 function initTimePicker(sel) {
-    // Initialize timepicker [MAT]
-    return $(sel).timepicker({
-        twelveHour: false,
-        autoClose: true,
-        i18n: {
-            cancel: 'Abbrechen',
-            clear: 'Löschen',
-            done: 'OK'
-        },
-    });
+  // Initialize timepicker [MAT]
+  return $(sel).timepicker({
+    twelveHour: false,
+    autoClose: true,
+    i18n: {
+      cancel: "Abbrechen",
+      clear: "Löschen",
+      done: "OK",
+    },
+  });
 }
 
 $(document).ready(function () {
-    $("dmc-datetime input").addClass("datepicker");
-    $("[data-form-control='date']").addClass("datepicker");
-    $("[data-form-control='time']").addClass("timepicker");
-
-    // Initialize sidenav [MAT]
-    $(".sidenav").sidenav();
-
-    // Initialize datepicker [MAT]
-    initDatePicker(".datepicker");
-
-    // Initialize timepicker [MAT]
-    initTimePicker(".timepicker");
-
-    // Initialize tooltip [MAT]
-    $('.tooltipped').tooltip();
-
-    // Initialize select [MAT]
-    $('select').formSelect();
-
-    // Initialize dropdown [MAT]
-    $('.dropdown-trigger').dropdown();
-    $('.navbar-dropdown-trigger').dropdown({
-        "coverTrigger": false,
-        "constrainWidth": false,
-    });
-
-    // If JS is activated, the language form will be auto-submitted
-    $('.language-field select').change(function () {
-        $(this).parents(".language-form").submit();
-    });
-
-    // If auto-submit is activated (see above), the language submit must not be visible
-    $(".language-submit-p").hide();
-
-    // Initalize print button
-    $("#print").click(function () {
-        window.print();
-    });
-
-    // Initialize Collapsible [MAT]
-    $('.collapsible').collapsible();
-
-    // Initialize FABs [MAT]
-    $('.fixed-action-btn').floatingActionButton();
-
-    // Initialize Modals [MAT]
-    $('.modal').modal();
-
-    // Initialize image boxes [Materialize]
-    $('.materialboxed').materialbox();
-
-    // Intialize Tabs [Materialize]
-    $('.tabs').tabs();
-
-    // Sync color picker
-    $(".jscolor").change(function () {
-        $("#" + $(this).data("preview")).css("color", $(this).val());
-    });
-
-    // Initialise auto-completion for search bar
-    window.autocomplete = new Autocomplete({minimum_length: 2});
-    window.autocomplete.setup();
-
-    // Initialize text collapsibles [MAT, own work]
-    $(".text-collapsible").addClass("closed").removeClass("opened");
-
-    $(".text-collapsible .open-icon").click(function (e) {
-        var el = $(e.target).parent();
-        el.addClass("opened").removeClass("closed");
-    });
-    $(".text-collapsible .close-icon").click(function (e) {
-        var el = $(e.target).parent();
-        el.addClass("closed").removeClass("opened");
-    });
-
-    // Initialize the service worker
-    if ('serviceWorker' in navigator) {
-        console.debug("Start registration of service worker.");
-        navigator.serviceWorker.register('/serviceworker.js', {
-            scope: '/'
-        }).then(function() {
-            console.debug("Service worker has been registered.");
-        }).catch(function() {
-            console.debug("Service worker registration has failed.")
-        });
-    }
+  $("dmc-datetime input").addClass("datepicker");
+  $("[data-form-control='date']").addClass("datepicker");
+  $("[data-form-control='time']").addClass("timepicker");
+
+  // Initialize sidenav [MAT]
+  $(".sidenav").sidenav();
+
+  // Initialize datepicker [MAT]
+  initDatePicker(".datepicker");
+
+  // Initialize timepicker [MAT]
+  initTimePicker(".timepicker");
+
+  // Initialize tooltip [MAT]
+  $(".tooltipped").tooltip();
+
+  // Initialize select [MAT]
+  $("select").formSelect();
+
+  // Initialize dropdown [MAT]
+  $(".dropdown-trigger").dropdown();
+  $(".navbar-dropdown-trigger").dropdown({
+    coverTrigger: false,
+    constrainWidth: false,
+  });
+
+  // If JS is activated, the language form will be auto-submitted
+  $(".language-field select").change(function () {
+    $(this).parents(".language-form").submit();
+  });
+
+  // If auto-submit is activated (see above), the language submit must not be visible
+  $(".language-submit-p").hide();
+
+  // Initalize print button
+  $("#print").click(function () {
+    window.print();
+  });
+
+  // Initialize Collapsible [MAT]
+  $(".collapsible").collapsible();
+
+  // Initialize FABs [MAT]
+  $(".fixed-action-btn").floatingActionButton();
+
+  // Initialize Modals [MAT]
+  $(".modal").modal();
+
+  // Initialize image boxes [Materialize]
+  $(".materialboxed").materialbox();
+
+  // Intialize Tabs [Materialize]
+  $(".tabs").tabs();
+
+  // Sync color picker
+  $(".jscolor").change(function () {
+    $("#" + $(this).data("preview")).css("color", $(this).val());
+  });
+
+  // Initialise auto-completion for search bar
+  window.autocomplete = new Autocomplete({
+    minimum_length: 2,
+    url: JSON.parse($("#search-snippet-url").text()),
+  });
+  window.autocomplete.setup();
+
+  // Initialize text collapsibles [MAT, own work]
+  $(".text-collapsible").addClass("closed").removeClass("opened");
+
+  $(".text-collapsible .open-icon").click(function (e) {
+    var el = $(e.target).parent();
+    el.addClass("opened").removeClass("closed");
+  });
+  $(".text-collapsible .close-icon").click(function (e) {
+    var el = $(e.target).parent();
+    el.addClass("closed").removeClass("opened");
+  });
+
+  // Initialize the service worker
+  if ("serviceWorker" in navigator) {
+    console.debug("Start registration of service worker.");
+    navigator.serviceWorker
+      .register("/serviceworker.js", {
+        scope: "/",
+      })
+      .then(function () {
+        console.debug("Service worker has been registered.");
+      })
+      .catch(function () {
+        console.debug("Service worker registration has failed.");
+      });
+  }
 });
 
 // Show notice if serviceworker broadcasts that the current page comes from its cache
 const channel = new BroadcastChannel("cache-or-not");
-channel.addEventListener("message", event => {
-    if ((event.data) && !($("#cache-alert").length)) {
-        $("main").prepend('<div id="cache-alert" class="alert warning"><p><i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>' + gettext("This page may contain outdated information since there is no internet connection.") + '</p> </div>')
-    }
+channel.addEventListener("message", (event) => {
+  if (event.data && !$("#cache-alert").length) {
+    $("main").prepend(
+      '<div id="cache-alert" class="alert warning"><p><i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>' +
+        gettext(
+          "This page may contain outdated information since there is no internet connection."
+        ) +
+        "</p> </div>"
+    );
+  }
 });
diff --git a/aleksis/core/static/js/multi_select.js b/aleksis/core/static/js/multi_select.js
index cddf911b5f50be2217075d2c0f34191adab6f72e..105f4c13d6632224a6a88ba2a1d4047255171439 100644
--- a/aleksis/core/static/js/multi_select.js
+++ b/aleksis/core/static/js/multi_select.js
@@ -1,48 +1,48 @@
 $(document).ready(function () {
-    $(".select--header-box").change(function () {
-        /*
+  $(".select--header-box").change(function () {
+    /*
         If the top checkbox is checked, all sub checkboxes should be checked,
         if it gets unchecked, all other ones should get unchecked.
         */
-        if ($(this).is(":checked")) {
-            $(this).closest("table").find('input[name="selected_objects"]').prop({
-                indeterminate: false,
-                checked: true,
-            });
-        } else {
-            $(this).closest("table").find('input[name="selected_objects"]').prop({
-                indeterminate: false,
-                checked: false,
-            });
-        }
-    });
+    if ($(this).is(":checked")) {
+      $(this).closest("table").find('input[name="selected_objects"]').prop({
+        indeterminate: false,
+        checked: true,
+      });
+    } else {
+      $(this).closest("table").find('input[name="selected_objects"]').prop({
+        indeterminate: false,
+        checked: false,
+      });
+    }
+  });
 
-    $('input[name="selected_objects"]').change(function () {
-        /*
+  $('input[name="selected_objects"]').change(function () {
+    /*
         If a table checkbox changes, check the state of the other ones.
         If all boxes are checked the box in the header should be checked,
         if all boxes are unchecked the header box should be unchecked. If
         only some boxes are checked the top one should be inderteminate.
          */
-        let checked = $(this).is(":checked");
-        let indeterminate = false;
-        let table = $(this).closest("table");
-        table.find('input[name="selected_objects"]').each(function () {
-            if ($(this).is(":checked") !== checked) {
-                /* Set the header box to indeterminate if the boxes are not the same */
-                table.find(".select--header-box").prop({
-                    indeterminate: true,
-                })
-                indeterminate = true;
-                return false;
-            }
+    let checked = $(this).is(":checked");
+    let indeterminate = false;
+    let table = $(this).closest("table");
+    table.find('input[name="selected_objects"]').each(function () {
+      if ($(this).is(":checked") !== checked) {
+        /* Set the header box to indeterminate if the boxes are not the same */
+        table.find(".select--header-box").prop({
+          indeterminate: true,
         });
-        if (!(indeterminate)) {
-            /* All boxes are the same, set the header box to the same value */
-            table.find(".select--header-box").prop({
-                indeterminate: false,
-                checked: checked,
-            });
-        }
+        indeterminate = true;
+        return false;
+      }
     });
+    if (!indeterminate) {
+      /* All boxes are the same, set the header box to the same value */
+      table.find(".select--header-box").prop({
+        indeterminate: false,
+        checked: checked,
+      });
+    }
+  });
 });
diff --git a/aleksis/core/static/js/progress.js b/aleksis/core/static/js/progress.js
deleted file mode 100644
index 8a97577e93d276cd62c69ead4c65b448a7631d49..0000000000000000000000000000000000000000
--- a/aleksis/core/static/js/progress.js
+++ /dev/null
@@ -1,84 +0,0 @@
-const OPTIONS = getJSONScript("progress_options");
-
-const STYLE_CLASSES = {
-    10: 'info',
-    20: 'info',
-    25: 'success',
-    30: 'warning',
-    40: 'error',
-};
-
-const ICONS = {
-    10: 'mdi:information',
-    20: 'mdi:information',
-    25: 'mdi:check-circle',
-    30: 'mdi:alert-outline',
-    40: 'mdi:alert-octagon-outline',
-};
-
-function setProgress(progress) {
-    $("#progress-bar").css("width", progress + "%");
-}
-
-function renderMessageBox(level, text) {
-    return '<div class="alert ' + STYLE_CLASSES[level] + '"><p><i class="material-icons iconify left" data-icon="' + ICONS[level] + '"></i>' + text + '</p></div>';
-}
-
-function updateMessages(messages) {
-    const messagesBox = $("#messages");
-
-    // Clear container
-    messagesBox.html("");
-
-    // Render message boxes
-    $.each(messages, function (i, message) {
-        messagesBox.append(renderMessageBox(message[0], message[1]));
-    });
-}
-
-function customProgress(progressBarElement, progressBarMessageElement, progress) {
-    setProgress(progress.percent);
-
-    if (progress.hasOwnProperty("messages")) {
-        updateMessages(progress.messages);
-    }
-}
-
-
-function customSuccess(progressBarElement, progressBarMessageElement, result) {
-    setProgress(100);
-    if (result) {
-        updateMessages(result);
-    }
-    $("#result-alert").addClass("success");
-    $("#result-icon").attr("data-icon", "mdi:check-circle-outline");
-    $("#result-text").text(OPTIONS.success);
-    $("#result-box").show();
-    $("#result-button").show();
-    const redirect = "redirect_on_success" in OPTIONS && OPTIONS.redirect_on_success;
-    if (redirect) {
-        window.location.replace(OPTIONS.redirect_on_success);
-    }
-}
-
-function customError(progressBarElement, progressBarMessageElement, excMessage) {
-    setProgress(100);
-    if (excMessage) {
-        updateMessages([40, excMessage]);
-    }
-    $("#result-alert").addClass("error");
-    $("#result-icon").attr("data-icon", "mdi:alert-octagon-outline");
-    $("#result-text").text(OPTIONS.error);
-    $("#result-box").show();
-}
-
-$(document).ready(function () {
-    $("#progress-bar").removeClass("indeterminate").addClass("determinate");
-
-    var progressUrl = Urls["taskStatus"](OPTIONS.task_id);
-    CeleryProgressBar.initProgressBar(progressUrl, {
-        onProgress: customProgress,
-        onSuccess: customSuccess,
-        onError: customError,
-    });
-});
diff --git a/aleksis/core/static/js/search.js b/aleksis/core/static/js/search.js
index fd121dbdfafe20c1c60ef2c793cd1f13b2a7573e..c24c79d747227ac71aa75901e8891da30956e7af 100644
--- a/aleksis/core/static/js/search.js
+++ b/aleksis/core/static/js/search.js
@@ -6,129 +6,129 @@
  */
 
 var Autocomplete = function (options) {
-    this.form_selector = options.form_selector || '.autocomplete';
-    this.url = options.url || Urls.searchbarSnippets();
-    this.delay = parseInt(options.delay || 300);
-    this.minimum_length = parseInt(options.minimum_length || 3);
-    this.form_elem = null;
-    this.query_box = null;
-    this.selected_element = null;
+  this.form_selector = options.form_selector || ".autocomplete";
+  this.url = options.url;
+  this.delay = parseInt(options.delay || 300);
+  this.minimum_length = parseInt(options.minimum_length || 3);
+  this.form_elem = null;
+  this.query_box = null;
+  this.selected_element = null;
 };
 
 Autocomplete.prototype.setup = function () {
-    var self = this;
-
-    this.form_elem = $(this.form_selector);
-    this.query_box = this.form_elem.find('input[name=q]');
-
-
-    $("#search-form").focusout(function (e) {
-        if (!$(e.relatedTarget).hasClass("search-item")) {
-            e.preventDefault();
-            $("#search-results").remove();
-        }
-    });
-
-    // Trigger the "keyup" event if input gets focused
-
-    this.query_box.focus(function () {
-        self.query_box.trigger("input");
-    });
-
-    this.query_box.on("input", () => {
-        console.log("Input changed, fetching again...")
-        var query = self.query_box.val();
-
-        if (query.length < self.minimum_length) {
-            $("#search-results").remove();
-            return true;
-        }
-
-        self.fetch(query);
-        return true;
-    });
-
-    // Watch the input box.
-    this.query_box.keydown(function (e) {
-
-        if (e.which === 38) { // Keypress Up
-            if (!self.selected_element) {
-                self.setSelectedResult($("#search-collection").children().last());
-                return false;
-            }
-
-            let prev = self.selected_element.prev();
-            if (prev.length > 0) {
-                self.setSelectedResult(prev);
-            }
-            return false;
-        }
-
-        if (e.which === 40) { // Keypress Down
-            if (!self.selected_element) {
-                self.setSelectedResult($("#search-collection").children().first());
-                return false;
-            }
-
-            let next = self.selected_element.next();
-            if (next.length > 0) {
-                self.setSelectedResult(next);
-            }
-            return false;
-        }
-
-        if (self.selected_element && e.which === 13) {
-            e.preventDefault();
-            window.location.href = self.selected_element.attr("href");
-        }
-    });
-
-    // // On selecting a result, remove result box
-    // this.form_elem.on('click', '#search-results', function (ev) {
-    //     $('#search-results').remove();
-    //     return true;
-    // });
-
-    // Disable browser's own autocomplete
-    // We do this here so users without JavaScript can keep it enabled
-    this.query_box.attr('autocomplete', 'off');
+  var self = this;
+
+  this.form_elem = $(this.form_selector);
+  this.query_box = this.form_elem.find("input[name=q]");
+
+  $("#search-form").focusout(function (e) {
+    if (!$(e.relatedTarget).hasClass("search-item")) {
+      e.preventDefault();
+      $("#search-results").remove();
+    }
+  });
+
+  // Trigger the "keyup" event if input gets focused
+
+  this.query_box.focus(function () {
+    self.query_box.trigger("input");
+  });
+
+  this.query_box.on("input", () => {
+    console.log("Input changed, fetching again...");
+    var query = self.query_box.val();
+
+    if (query.length < self.minimum_length) {
+      $("#search-results").remove();
+      return true;
+    }
+
+    self.fetch(query);
+    return true;
+  });
+
+  // Watch the input box.
+  this.query_box.keydown(function (e) {
+    if (e.which === 38) {
+      // Keypress Up
+      if (!self.selected_element) {
+        self.setSelectedResult($("#search-collection").children().last());
+        return false;
+      }
+
+      let prev = self.selected_element.prev();
+      if (prev.length > 0) {
+        self.setSelectedResult(prev);
+      }
+      return false;
+    }
+
+    if (e.which === 40) {
+      // Keypress Down
+      if (!self.selected_element) {
+        self.setSelectedResult($("#search-collection").children().first());
+        return false;
+      }
+
+      let next = self.selected_element.next();
+      if (next.length > 0) {
+        self.setSelectedResult(next);
+      }
+      return false;
+    }
+
+    if (self.selected_element && e.which === 13) {
+      e.preventDefault();
+      window.location.href = self.selected_element.attr("href");
+    }
+  });
+
+  // // On selecting a result, remove result box
+  // this.form_elem.on('click', '#search-results', function (ev) {
+  //     $('#search-results').remove();
+  //     return true;
+  // });
+
+  // Disable browser's own autocomplete
+  // We do this here so users without JavaScript can keep it enabled
+  this.query_box.attr("autocomplete", "off");
 };
 
 Autocomplete.prototype.fetch = function (query) {
-    var self = this;
-
-    $.ajax({
-        url: this.url,
-        data: {
-            'q': query
-        },
-        beforeSend: (request, settings) => {
-            $('#search-results').remove();
-            self.setLoader(true);
-        },
-        success: function (data) {
-            self.setLoader(false);
-            self.show_results(data);
-        }
-    })
+  var self = this;
+
+  $.ajax({
+    url: this.url,
+    data: {
+      q: query,
+    },
+    beforeSend: (request, settings) => {
+      $("#search-results").remove();
+      self.setLoader(true);
+    },
+    success: function (data) {
+      self.setLoader(false);
+      self.show_results(data);
+    },
+  });
 };
 
 Autocomplete.prototype.show_results = function (data) {
-    $('#search-results').remove();
-    var results_wrapper = $('<div id="search-results">' + data + '</div>');
-    this.query_box.after(results_wrapper);
-    this.selected_element = null;
+  $("#search-results").remove();
+  var results_wrapper = $('<div id="search-results">' + data + "</div>");
+  this.query_box.after(results_wrapper);
+  this.selected_element = null;
 };
 
 Autocomplete.prototype.setSelectedResult = function (element) {
-    if (this.selected_element) {
-        this.selected_element.removeClass("active");
-    }
-    element.addClass("active");
-    this.selected_element = element;
-    console.log("New element: ", element);
+  if (this.selected_element) {
+    this.selected_element.removeClass("active");
+  }
+  element.addClass("active");
+  this.selected_element = element;
+  console.log("New element: ", element);
 };
 
 Autocomplete.prototype.setLoader = function (value) {
-        $("#search-loader").css("display", (value === true ? "block" : "none"))
-}
+  $("#search-loader").css("display", value === true ? "block" : "none");
+};
diff --git a/aleksis/core/static/js/serviceworker.js b/aleksis/core/static/js/serviceworker.js
index 16382ec00c5dadb22c63060a227451377202d363..8fa870824dd46202731afd8dd439b15aad1da4c5 100644
--- a/aleksis/core/static/js/serviceworker.js
+++ b/aleksis/core/static/js/serviceworker.js
@@ -1,83 +1,89 @@
-
 // This is the AlekSIS service worker
 
-const CACHE = 'aleksis-cache';
+const CACHE = "aleksis-cache";
 
-const offlineFallbackPage = 'offline/';
+const offlineFallbackPage = "offline/";
 
-const channel = new BroadcastChannel('cache-or-not');
+const channel = new BroadcastChannel("cache-or-not");
 
 var comesFromCache = false;
 
 self.addEventListener("install", function (event) {
-    console.log("[AlekSIS PWA] Install Event processing.");
+  console.log("[AlekSIS PWA] Install Event processing.");
 
-    console.log("[AlekSIS PWA] Skipping waiting on install.");
-    self.skipWaiting();
+  console.log("[AlekSIS PWA] Skipping waiting on install.");
+  self.skipWaiting();
 
-    event.waitUntil(
-        caches.open(CACHE).then(function (cache) {
-            console.log("[AlekSIS PWA] Caching pages during install.");
-            return cache.add(offlineFallbackPage);
-        })
-    );
+  event.waitUntil(
+    caches.open(CACHE).then(function (cache) {
+      console.log("[AlekSIS PWA] Caching pages during install.");
+      return cache.add(offlineFallbackPage);
+    })
+  );
 });
 
 // Allow sw to control of current page
 self.addEventListener("activate", function (event) {
-    console.log("[AlekSIS PWA] Claiming clients for current page.");
-    event.waitUntil(self.clients.claim());
+  console.log("[AlekSIS PWA] Claiming clients for current page.");
+  event.waitUntil(self.clients.claim());
 });
 
 // If any fetch fails, it will look for the request in the cache and serve it from there first
 self.addEventListener("fetch", function (event) {
-    if (event.request.method !== "GET") return;
-    networkFirstFetch(event);
-    if (comesFromCache) channel.postMessage(true);
+  if (event.request.method !== "GET") return;
+  networkFirstFetch(event);
+  if (comesFromCache) channel.postMessage(true);
 });
 
 function networkFirstFetch(event) {
-    event.respondWith(
-        fetch(event.request)
-            .then(function (response) {
-                // If request was successful, add or update it in the cache
-                console.log("[AlekSIS PWA] Network request successful.");
-                event.waitUntil(updateCache(event.request, response.clone()));
-                comesFromCache = false;
-                return response;
-            })
-            .catch(function (error) {
-                console.log("[AlekSIS PWA] Network request failed. Serving content from cache: " + error);
-                return fromCache(event);
-            })
-    );
+  event.respondWith(
+    fetch(event.request)
+      .then(function (response) {
+        // If request was successful, add or update it in the cache
+        console.log("[AlekSIS PWA] Network request successful.");
+        event.waitUntil(updateCache(event.request, response.clone()));
+        comesFromCache = false;
+        return response;
+      })
+      .catch(function (error) {
+        console.log(
+          "[AlekSIS PWA] Network request failed. Serving content from cache: " +
+            error
+        );
+        return fromCache(event);
+      })
+  );
 }
 
 function fromCache(event) {
-    // Check to see if you have it in the cache
-    // Return response
-    // If not in the cache, then return offline fallback page
-    return caches.open(CACHE).then(function (cache) {
-        return cache.match(event.request)
-            .then(function (matching) {
-                if (!matching || matching.status === 404) {
-                    console.log("[AlekSIS PWA] Cache request failed. Serving offline fallback page.");
-                    comesFromCache = false;
-                    // Use the precached offline page as fallback
-                    return caches.match(offlineFallbackPage);
-                }
-                comesFromCache = true;
-                return matching;
-            });
+  // Check to see if you have it in the cache
+  // Return response
+  // If not in the cache, then return offline fallback page
+  return caches.open(CACHE).then(function (cache) {
+    return cache.match(event.request).then(function (matching) {
+      if (!matching || matching.status === 404) {
+        console.log(
+          "[AlekSIS PWA] Cache request failed. Serving offline fallback page."
+        );
+        comesFromCache = false;
+        // Use the precached offline page as fallback
+        return caches.match(offlineFallbackPage);
+      }
+      comesFromCache = true;
+      return matching;
     });
+  });
 }
 
 function updateCache(request, response) {
-    if (response.headers.get('cache-control') && response.headers.get('cache-control').includes('no-cache')) {
-        return Promise.resolve();
-    } else {
-        return caches.open(CACHE).then(function (cache) {
-            return cache.put(request, response);
-        });
-    }
+  if (
+    response.headers.get("cache-control") &&
+    response.headers.get("cache-control").includes("no-cache")
+  ) {
+    return Promise.resolve();
+  } else {
+    return caches.open(CACHE).then(function (cache) {
+      return cache.put(request, response);
+    });
+  }
 }
diff --git a/aleksis/core/static/print-simple.css b/aleksis/core/static/print-simple.css
new file mode 100644
index 0000000000000000000000000000000000000000..dfde8908dbe44e0bfa21039364455ed4b9154906
--- /dev/null
+++ b/aleksis/core/static/print-simple.css
@@ -0,0 +1,25 @@
+@page {
+  padding: 0;
+  margin: 0;
+}
+
+table.small-print,
+td.small-print,
+th.small-print {
+  font-size: 10pt;
+}
+
+tr {
+  border-bottom: 1px solid rgba(0, 0, 0, 0.3);
+}
+
+td,
+th {
+  padding: 1px;
+}
+
+td.rotate,
+th.rotate {
+  text-align: center;
+  transform: rotate(-90deg);
+}
diff --git a/aleksis/core/static/print.css b/aleksis/core/static/print.css
index cda82eacab1d51a39f7f455170eb6ff12121dc30..1c3e9d486a27e913671a453219c60b70b8778462 100644
--- a/aleksis/core/static/print.css
+++ b/aleksis/core/static/print.css
@@ -1,132 +1,139 @@
 .sheet.infinite {
-    height: auto !important;
+  height: auto !important;
 }
 
 @page {
-    size: A4;
-    padding: 30mm;
-    margin: 0;
+  size: A4;
+  padding: 30mm;
+  margin: 0;
 }
 
 header {
-    display: block;
-    width: 190mm;
+  display: block;
+  width: 190mm;
 }
 
-
 #print-header {
-    display: block !important;
-    border-bottom: 1px solid;
-    margin-bottom: 0;
-    height: 22mm;
-    background: white;
+  display: block !important;
+  border-bottom: 1px solid;
+  margin-bottom: 0;
+  height: 22mm;
+  background: white;
 }
 
-header, main, footer {
-    margin: 0;
+header,
+main,
+footer {
+  margin: 0;
 }
 
 #print-header .col.right-align {
-    padding: 15px;
+  padding: 15px;
 }
 
 .sheet {
-    padding: 10mm;
+  padding: 10mm;
 }
 
-
-.header-space, .footer-space {
-    height: 0;
+.header-space,
+.footer-space {
+  height: 0;
 }
 
-.print-layout-table, .print-layout-td {
-    width: 190mm;
-    max-width: 190mm;
-    min-width: 190mm;
+.print-layout-table,
+.print-layout-td {
+  width: 190mm;
+  max-width: 190mm;
+  min-width: 190mm;
 }
 
 .print-layout-td {
-    padding: 0;
+  padding: 0;
 }
 
 .print-layout-table .no-border {
-    border: 0;
+  border: 0;
 }
 
-
 footer {
-    margin-top: 5mm;
-    text-align: center;
-    width: 190mm;
-
+  margin-top: 5mm;
+  text-align: center;
+  width: 190mm;
 }
 
-header .row, header .col {
-    padding: 0 !important;
-    margin: 0 !important;
+header .row,
+header .col {
+  padding: 0 !important;
+  margin: 0 !important;
 }
 
 #print-logo {
-    padding: 2mm;
-    height: 22mm;
-    width: auto;
+  height: 22mm;
+  width: auto;
+  margin-block: 0;
+  padding: 2mm 2mm 2mm 0;
 }
 
 .page-break {
-    display: block;
-    text-align: center;
-    margin: auto;
-    margin-top: 20px;
-    margin-bottom: 20px;
-    width: 200px;
-    border-top: 1px dashed;
-    color: darkgrey;
-    page-break-after: always;
+  display: block;
+  text-align: center;
+  margin: auto;
+  margin-top: 20px;
+  margin-bottom: 20px;
+  width: 200px;
+  border-top: 1px dashed;
+  color: darkgrey;
+  page-break-after: always;
 }
 
 @media print {
-    .header-space {
-        height: 35mm;
-    }
+  .header-space {
+    height: 35mm;
+  }
 
-    .footer-space {
-        height: 20mm
-    }
+  .footer-space {
+    height: 20mm;
+  }
 
-    header, footer {
-        height: 22mm;
-    }
+  header,
+  footer {
+    height: 22mm;
+  }
 
-    header {
-        position: fixed;
-        top: 10mm;
-    }
+  header {
+    position: fixed;
+    top: 10mm;
+  }
 
-    footer {
-        position: fixed;
-        bottom: 0;
-    }
+  footer {
+    position: fixed;
+    bottom: 0;
+  }
 
-    .page-break {
-        border: white;
-    }
+  .page-break {
+    border: white;
+  }
 }
 
 /* Some stuff for tables */
 
-table.small-print, td.small-print, th.small-print {
-    font-size: 10pt;
+table.small-print,
+td.small-print,
+th.small-print {
+  font-size: 10pt;
 }
 
 tr {
-    border-bottom: 1px solid rgba(0, 0, 0, 0.3);
+  border-bottom: 1px solid rgba(0, 0, 0, 0.3);
 }
 
-td, th {
-    padding: 1px;
+td,
+th {
+  padding: 1px;
 }
 
-td.rotate, th.rotate {
-    text-align: center;
-    transform: rotate(-90deg);
+td.rotate,
+th.rotate {
+  text-align: center;
+  transform: rotate(-90deg);
 }
diff --git a/aleksis/core/static/print_landscape.css b/aleksis/core/static/print_landscape.css
index a348ddff6268f56a1f4fa82eac240d6c9823e14c..746968664ee7ac8e04643e97e6964cfc57796eca 100644
--- a/aleksis/core/static/print_landscape.css
+++ b/aleksis/core/static/print_landscape.css
@@ -1,19 +1,18 @@
 @page {
-    size: A4 landscape;
+  size: A4 landscape;
 }
 
 header {
-    width: 277mm;
+  width: 277mm;
 }
 
-
-.print-layout-table, .print-layout-td {
-    width: 277mm;
-    max-width: 277mm;
-    min-width: 277mm;
+.print-layout-table,
+.print-layout-td {
+  width: 277mm;
+  max-width: 277mm;
+  min-width: 277mm;
 }
 
-
 footer {
-    width: 277mm;
+  width: 277mm;
 }
diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss
index 07f1881216448b3c82fb605af72b214004bf2f03..998da7726f3758310068225b7d929be51975eb14 100644
--- a/aleksis/core/static/public/style.scss
+++ b/aleksis/core/static/public/style.scss
@@ -4,7 +4,8 @@
   background-color: $primary-color !important;
 }
 
-.primary-color-text, .primary-color-text a {
+.primary-color-text,
+.primary-color-text a {
   color: $primary-color !important;
 }
 
@@ -12,7 +13,8 @@
   background-color: $secondary-color !important;
 }
 
-.secondary-color-text, .secondary-color-text a {
+.secondary-color-text,
+.secondary-color-text a {
   color: $secondary-color !important;
 }
 
@@ -29,7 +31,7 @@ rect#background {
 }
 
 .success {
-  @extend .light-green, .lighten-3
+  @extend .light-green, .lighten-3;
 }
 
 .success-text {
@@ -64,16 +66,22 @@ body {
   flex-direction: column;
 }
 
-header, main, footer {
+header,
+main,
+footer {
   margin-left: 300px;
 }
 
-.without-menu header, .without-menu main, .without-menu footer {
+.without-menu header,
+.without-menu main,
+.without-menu footer {
   margin-left: 0;
 }
 
 @media only screen and (max-width: 992px) {
-  header, main, footer {
+  header,
+  main,
+  footer {
     margin-left: 0;
   }
 }
@@ -81,7 +89,10 @@ header, main, footer {
 .materialize-circle {
   @extend .circle;
 }
-.collection .collection-item.avatar > .materialize-circle > .materialize-circle {
+.collection
+  .collection-item.avatar
+  > .materialize-circle
+  > .materialize-circle {
   left: 0;
 }
 
@@ -98,7 +109,6 @@ header, main, footer {
   width: auto;
 }
 
-
 /********/
 /* MAIN */
 /********/
@@ -134,11 +144,18 @@ ul.sidenav li.logo > a:hover {
   background: none !important;
 }
 
-.sidenav .collapsible-body > ul:not(.collapsible) > li.active a > i, .sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active a > i {
+.sidenav .collapsible-body > ul:not(.collapsible) > li.active a > i,
+.sidenav.sidenav-fixed
+  .collapsible-body
+  > ul:not(.collapsible)
+  > li.active
+  a
+  > i {
   color: #fff;
 }
 
-.sidenav .collapsible-body > ul:not(.collapsible) > li.active, .sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active {
+.sidenav .collapsible-body > ul:not(.collapsible) > li.active,
+.sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active {
   background-color: lighten($primary-color, 5%);
 }
 
@@ -161,8 +178,8 @@ ul.sidenav li.logo > a:hover {
   border-top: 1px solid rgba(0, 0, 0, 0.14);
   border-bottom: 1px solid rgba(0, 0, 0, 0.14);
 
-  -webkit-transition: margin .25s ease;
-  transition: margin .25s ease;
+  -webkit-transition: margin 0.25s ease;
+  transition: margin 0.25s ease;
 }
 
 .sidenav li.search .search-wrapper input#search {
@@ -215,7 +232,6 @@ div#search-results {
   right: 10px;
 }
 
-
 // Footer
 
 .footer-icon {
@@ -223,7 +239,6 @@ div#search-results {
   vertical-align: middle;
 }
 
-
 @media only screen and (min-width: 1384px) {
   .footer-row-large {
     display: flex;
@@ -280,10 +295,17 @@ h1 {
 
 h2 {
   font-weight: 300;
-  font-size: 3.0rem;
+  font-size: 3rem;
 }
 
-p, h1, h2, h3, h4, h5, h6, .card-title {
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.card-title {
   overflow-wrap: break-word;
   hyphens: auto;
 }
@@ -294,7 +316,6 @@ ul.collection .collection-item .title {
   font-weight: bold;
 }
 
-
 // Forms
 
 form .row {
@@ -317,7 +338,7 @@ label.chips-checkbox {
   height: 32px;
   font-size: 13px;
   font-weight: 500;
-  color: rgba(0, 0, 0, .6);
+  color: rgba(0, 0, 0, 0.6);
   line-height: 32px;
   padding: 0 12px;
   border-radius: 16px;
@@ -403,24 +424,29 @@ span.badge .material-icons {
   font-size: 2rem;
 }
 
-.btn.primary, .btn-large.primary, .btn-small.primary {
+.btn.primary,
+.btn-large.primary,
+.btn-small.primary {
   background-color: rgba(0, 0, 0, 0.05) !important;
   color: black !important;
 }
 
-.btn.primary:hover, .btn-large.primary:hover, .btn-small.primary {
+.btn.primary:hover,
+.btn-large.primary:hover,
+.btn-small.primary {
   background-color: $primary-color !important;
   color: whitesmoke !important;
 }
 
-
 /* Table*/
 
 .table-container {
   overflow-x: auto;
 }
 
-table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped tr {
+table.striped > tbody > tr:nth-child(odd),
+table tr.striped,
+table tbody.striped tr {
   background-color: rgba(208, 208, 208, 0.5);
 }
 
@@ -461,7 +487,9 @@ th.orderable.desc {
     font-size: 15px;
   }
 
-  header, main, footer {
+  header,
+  main,
+  footer {
     margin-left: 0;
   }
 
@@ -488,11 +516,14 @@ th.orderable.desc {
     padding: 15px;
   }
 
-  main, header {
+  main,
+  header {
     padding: 0;
   }
 
-  footer, footer .footer-copyright, footer .container {
+  footer,
+  footer .footer-copyright,
+  footer .container {
     background-color: white !important;
     color: black !important;
   }
@@ -501,7 +532,8 @@ th.orderable.desc {
     display: none;
   }
 
-  .footer-copyright, .footer-copyright .container {
+  .footer-copyright,
+  .footer-copyright .container {
     padding: 0 !important;
     margin: 0 !important;
   }
@@ -513,7 +545,8 @@ th.orderable.desc {
 
 // Alerts
 
-.alert ul, .alert p {
+.alert ul,
+.alert p {
   margin: 0;
 }
 
@@ -637,7 +670,6 @@ main figure.alert {
   margin-bottom: 5px;
 }
 
-
 /* Dashboard */
 
 .card-action-badge {
@@ -719,7 +751,6 @@ main figure.alert {
   }
 }
 
-
 .dashboard-cards .card {
   display: inline-block;
   overflow: visible;
@@ -755,14 +786,15 @@ main figure.alert {
 }
 
 /* Tabs with icons */
-.tabs-icons, .tabs-icons .tab, .tabs-icons a {
+.tabs-icons,
+.tabs-icons .tab,
+.tabs-icons a {
   height: 72px;
 }
 
 .tabs-icons .tab {
   display: inline-flex;
   flex-direction: column;
-
 }
 
 .tabs-icons .tab a {
@@ -798,7 +830,8 @@ $person-logo-size: 20vh;
   }
 }
 
-.clip-circle.no-image, .clip-circle.no-image > i.material-icons {
+.clip-circle.no-image,
+.clip-circle.no-image > i.material-icons {
   font-size: calc(#{$person-logo-size} * 0.5);
   color: #6f6f6f;
   background: #f2f2f2;
@@ -817,8 +850,8 @@ $person-logo-size: 20vh;
   justify-content: space-between;
   padding: 0 1rem;
   > a {
-    position: static!important;
-    transform: none!important;
+    position: static !important;
+    transform: none !important;
   }
   & .nav-spacer {
     width: 60px;
@@ -840,16 +873,17 @@ $person-logo-size: 20vh;
 
 .navbar-dropdown-trigger .clip-circle {
   margin: auto;
-  width: $navbar-height*0.75;
-  height: $navbar-height*0.75;
+  width: $navbar-height * 0.75;
+  height: $navbar-height * 0.75;
   cursor: pointer;
 
-  &.no-image, &.no-image > i.material-icons {
+  &.no-image,
+  &.no-image > i.material-icons {
     font-size: calc(#{$navbar-height} * 0.75 * 0.5);
     color: #6f6f6f;
     background: #f2f2f2;
-    line-height: $navbar-height*0.75;
-    width: $navbar-height*0.75;
+    line-height: $navbar-height * 0.75;
+    width: $navbar-height * 0.75;
     cursor: pointer;
   }
 }
@@ -878,8 +912,8 @@ a.new-notification {
   background-color: lighten($primary-color, 30%);
   z-index: -1;
   box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14) inset,
-  0 1px 10px 0 rgba(0, 0, 0, 0.12) inset,
-  0 2px 4px -1px rgba(0, 0, 0, 0.3) inset;
+    0 1px 10px 0 rgba(0, 0, 0, 0.12) inset,
+    0 2px 4px -1px rgba(0, 0, 0, 0.3) inset;
 }
 
 .person-buttons {
@@ -954,7 +988,6 @@ a.new-notification {
   height: 20vh;
 }
 
-
 .application-circle img {
   @extend .application-circle;
   object-fit: cover;
@@ -964,7 +997,8 @@ svg.iconify {
   @extend i;
 }
 
-.btn .iconify.material-icons, .btn-flat .iconify.material-icons{
+.btn .iconify.material-icons,
+.btn-flat .iconify.material-icons {
   height: $button-height;
 }
 
@@ -992,7 +1026,8 @@ p.ical-description {
   font-weight: 300;
 }
 
-.table-circle, .table-circle .materialize-circle {
+.table-circle,
+.table-circle .materialize-circle {
   height: 4em;
   width: 4em;
 }
diff --git a/aleksis/core/static/public/theme.scss b/aleksis/core/static/public/theme.scss
index 1b38e9bda22c7b63601a4a227d58981e2e3a8d45..3850e4c17da80967b7729bd5feebf937ee3cee5e 100644
--- a/aleksis/core/static/public/theme.scss
+++ b/aleksis/core/static/public/theme.scss
@@ -29,28 +29,30 @@
 //  23. Collections
 //  24. Progress Bar
 
-
-
 // 1. Colors
 // ==========================================================================
 
-$primary-color: adjust-color(get-colour(get-preference(theme, primary)), $alpha: 1);
+$primary-color: adjust-color(
+  get-colour(get-preference(theme, primary)),
+  $alpha: 1
+);
 $primary-color-light: lighten($primary-color, 15%) !default;
 $primary-color-dark: darken($primary-color, 15%) !default;
 
-$secondary-color: adjust-color(get-colour(get-preference(theme, secondary)), $alpha: 1);
+$secondary-color: adjust-color(
+  get-colour(get-preference(theme, secondary)),
+  $alpha: 1
+);
 $success-color: color("green", "base") !default;
 $error-color: color("red", "base") !default;
 $link-color: color("light-blue", "darken-1") !default;
 
-
 // 2. Badges
 // ==========================================================================
 
 $badge-bg-color: $secondary-color !default;
 $badge-height: 22px !default;
 
-
 // 3. Buttons
 // ==========================================================================
 
@@ -64,12 +66,15 @@ $button-padding: 0 16px !default;
 $button-radius: 2px !default;
 
 // Disabled styles
-$button-disabled-background: #DFDFDF !default;
-$button-disabled-color: #9F9F9F !default;
+$button-disabled-background: #dfdfdf !default;
+$button-disabled-color: #9f9f9f !default;
 
 // Raised buttons
 $button-raised-background: $secondary-color !default;
-$button-raised-background-hover: lighten($button-raised-background, 5%) !default;
+$button-raised-background-hover: lighten(
+  $button-raised-background,
+  5%
+) !default;
 $button-raised-color: #fff !default;
 
 // Large buttons
@@ -81,8 +86,8 @@ $button-floating-large-size: 56px !default;
 // Small buttons
 $button-small-font-size: 13px !default;
 $button-small-icon-font-size: 1.2rem !default;
-$button-small-height: $button-height * .9 !default;
-$button-floating-small-size: $button-height * .9 !default;
+$button-small-height: $button-height * 0.9 !default;
+$button-floating-small-size: $button-height * 0.9 !default;
 
 // Flat buttons
 $button-flat-color: #343434 !default;
@@ -95,7 +100,6 @@ $button-floating-color: #fff !default;
 $button-floating-size: 40px !default;
 $button-floating-radius: 50% !default;
 
-
 // 4. Cards
 // ==========================================================================
 
@@ -104,7 +108,6 @@ $card-bg-color: #fff !default;
 $card-link-color: $primary-color !default;
 $card-link-color-light: lighten($card-link-color, 20%) !default;
 
-
 // 5. Carousel
 // ==========================================================================
 
@@ -112,7 +115,6 @@ $carousel-height: 400px !default;
 $carousel-item-height: $carousel-height / 2 !default;
 $carousel-item-width: $carousel-item-height !default;
 
-
 // 6. Collapsible
 // ==========================================================================
 
@@ -121,7 +123,6 @@ $collapsible-line-height: $collapsible-height !default;
 $collapsible-header-color: #fff !default;
 $collapsible-border-color: #ddd !default;
 
-
 // 7. Chips
 // ==========================================================================
 
@@ -130,26 +131,30 @@ $chip-border-color: #9e9e9e !default;
 $chip-selected-color: $primary-color !default;
 $chip-margin: 5px !default;
 
-
 // 8. Date + Time Picker
 // ==========================================================================
 
 $datepicker-display-font-size: 2.8rem;
 $datepicker-calendar-header-color: #999;
-$datepicker-weekday-color: rgba(0, 0, 0, .87) !default;
+$datepicker-weekday-color: rgba(0, 0, 0, 0.87) !default;
 $datepicker-weekday-bg: darken($secondary-color, 7%) !default;
 $datepicker-date-bg: $secondary-color !default;
-$datepicker-year: rgba(255, 255, 255, .7) !default;
-$datepicker-focus: rgba(0,0,0, .05) !default;
+$datepicker-year: rgba(255, 255, 255, 0.7) !default;
+$datepicker-focus: rgba(0, 0, 0, 0.05) !default;
 $datepicker-selected: $secondary-color !default;
-$datepicker-selected-outfocus: desaturate(lighten($secondary-color, 35%), 15%) !default;
-$datepicker-day-focus: transparentize(desaturate($secondary-color, 5%), .75) !default;
-$datepicker-disabled-day-color: rgba(0, 0, 0, .3) !default;
-
-$timepicker-clock-color: rgba(0, 0, 0, .87) !default;
+$datepicker-selected-outfocus: desaturate(
+  lighten($secondary-color, 35%),
+  15%
+) !default;
+$datepicker-day-focus: transparentize(
+  desaturate($secondary-color, 5%),
+  0.75
+) !default;
+$datepicker-disabled-day-color: rgba(0, 0, 0, 0.3) !default;
+
+$timepicker-clock-color: rgba(0, 0, 0, 0.87) !default;
 $timepicker-clock-plate-bg: #eee !default;
 
-
 // 9. Dropdown
 // ==========================================================================
 
@@ -158,7 +163,6 @@ $dropdown-hover-bg-color: #eee !default;
 $dropdown-color: $secondary-color !default;
 $dropdown-item-height: 50px !default;
 
-
 // 10. Forms
 // ==========================================================================
 
@@ -174,8 +178,8 @@ $input-font-size: 16px !default;
 $input-margin-bottom: 8px;
 $input-margin: 0 0 $input-margin-bottom 0 !default;
 $input-padding: 0 !default;
-$label-font-size: .8rem !default;
-$input-disabled-color: rgba(0,0,0, .42) !default;
+$label-font-size: 0.8rem !default;
+$input-disabled-color: rgba(0, 0, 0, 0.42) !default;
 $input-disabled-solid-color: #949494 !default;
 $input-disabled-border: 1px dotted $input-disabled-color !default;
 $input-invalid-border: 1px solid $input-error-color !default;
@@ -194,23 +198,25 @@ $track-height: 3px !default;
 
 // Select
 $select-border: 1px solid #f2f2f2 !default;
-$select-background: rgba(255, 255, 255, 0.90) !default;
+$select-background: rgba(255, 255, 255, 0.9) !default;
 $select-focus: 1px solid lighten($secondary-color, 47%) !default;
-$select-option-hover: rgba(0,0,0,.08) !default;
-$select-option-focus: rgba(0,0,0,.08) !default;
-$select-option-selected: rgba(0,0,0,.03) !default;
+$select-option-hover: rgba(0, 0, 0, 0.08) !default;
+$select-option-focus: rgba(0, 0, 0, 0.08) !default;
+$select-option-selected: rgba(0, 0, 0, 0.03) !default;
 $select-padding: 5px !default;
 $select-radius: 2px !default;
-$select-disabled-color: rgba(0,0,0,.3) !default;
+$select-disabled-color: rgba(0, 0, 0, 0.3) !default;
 
 // Switches
 $switch-bg-color: $secondary-color !default;
-$switch-checked-lever-bg: desaturate(lighten($switch-bg-color, 25%), 25%) !default;
-$switch-unchecked-bg: #F1F1F1 !default;
-$switch-unchecked-lever-bg: rgba(0,0,0,.38) !default;
+$switch-checked-lever-bg: desaturate(
+  lighten($switch-bg-color, 25%),
+  25%
+) !default;
+$switch-unchecked-bg: #f1f1f1 !default;
+$switch-unchecked-lever-bg: rgba(0, 0, 0, 0.38) !default;
 $switch-radius: 15px !default;
 
-
 // 11. Global
 // ==========================================================================
 
@@ -229,15 +235,13 @@ $small-and-down: "only screen and (max-width : #{$small-screen})" !default;
 $medium-and-down: "only screen and (max-width : #{$medium-screen})" !default;
 $medium-only: "only screen and (min-width : #{$small-screen-up}) and (max-width : #{$medium-screen})" !default;
 
-
 // 12. Grid
 // ==========================================================================
 
 $num-cols: 12 !default;
 $gutter-width: 1.5rem !default;
 $element-top-margin: $gutter-width/3 !default;
-$element-bottom-margin: ($gutter-width*2)/3 !default;
-
+$element-bottom-margin: ($gutter-width * 2)/3 !default;
 
 // 13. Navigation Bar
 // ==========================================================================
@@ -255,27 +259,24 @@ $navbar-brand-font-size: 2.1rem !default;
 
 $sidenav-width: 300px !default;
 $sidenav-font-size: 14px !default;
-$sidenav-font-color: rgba(0,0,0,.87) !default;
+$sidenav-font-color: rgba(0, 0, 0, 0.87) !default;
 $sidenav-bg-color: #fff !default;
 $sidenav-padding: 16px !default;
 $sidenav-item-height: 48px !default;
 $sidenav-line-height: $sidenav-item-height !default;
 
-
 // 15. Photo Slider
 // ==========================================================================
 
-$slider-bg-color: color('grey', 'base') !default;
-$slider-bg-color-light: color('grey', 'lighten-2') !default;
-$slider-indicator-color: color('green', 'base') !default;
-
+$slider-bg-color: color("grey", "base") !default;
+$slider-bg-color-light: color("grey", "lighten-2") !default;
+$slider-indicator-color: color("green", "base") !default;
 
 // 16. Spinners | Loaders
 // ==========================================================================
 
 $spinner-default-color: $secondary-color !default;
 
-
 // 17. Tabs
 // ==========================================================================
 
@@ -283,14 +284,12 @@ $tabs-underline-color: $primary-color-light !default;
 $tabs-text-color: $primary-color !default;
 $tabs-bg-color: #fff !default;
 
-
 // 18. Tables
 // ==========================================================================
 
-$table-border-color: rgba(0,0,0,.12) !default;
+$table-border-color: rgba(0, 0, 0, 0.12) !default;
 $table-striped-color: rgba(242, 242, 242, 0.5) !default;
 
-
 // 19. Toasts
 // ==========================================================================
 
@@ -299,11 +298,11 @@ $toast-color: #323232 !default;
 $toast-text-color: #fff !default;
 $toast-action-color: #eeff41;
 
-
 // 20. Typography
 // ==========================================================================
 
-$font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !default;
+$font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
+  Ubuntu, Cantarell, "Helvetica Neue", sans-serif !default;
 $off-black: rgba(0, 0, 0, 0.87) !default;
 // Header Styles
 $h1-fontsize: 4.2rem !default;
@@ -313,24 +312,21 @@ $h4-fontsize: 2.28rem !default;
 $h5-fontsize: 1.64rem !default;
 $h6-fontsize: 1.15rem !default;
 
-
 // 21. Footer
 // ==========================================================================
 
 $footer-font-color: #fff !default;
 $footer-bg-color: $primary-color !default;
-$footer-copyright-font-color: rgba(255,255,255,.8) !default;
-$footer-copyright-bg-color: rgba(51,51,51,.08) !default;
-
+$footer-copyright-font-color: rgba(255, 255, 255, 0.8) !default;
+$footer-copyright-bg-color: rgba(51, 51, 51, 0.08) !default;
 
 // 22. Flow Text
 // ==========================================================================
 
-$range : $large-screen - $small-screen !default;
+$range: $large-screen - $small-screen !default;
 $intervals: 20 !default;
 $interval-size: $range / $intervals !default;
 
-
 // 23. Collections
 // ==========================================================================
 
@@ -342,7 +338,6 @@ $collection-hover-bg-color: #ddd !default;
 $collection-link-color: $secondary-color !default;
 $collection-line-height: 1.5rem !default;
 
-
 // 24. Progress Bar
 // ==========================================================================
 
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index a58c3fc443b3ad66c8df31109cbb6276192518ef..c912d13477c607e3374b3c8b4619338a8cb8a6a0 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -156,7 +156,7 @@ class PersonColumn(tables.Column):
     """Returns person object from given id."""
 
     def render(self, value):
-        return Person.objects.get(user__id=value)
+        return Person.objects.get(pk=value)
 
 
 class InvitationCodeColumn(tables.Column):
diff --git a/aleksis/core/tasks.py b/aleksis/core/tasks.py
index 7b7e529b3a1fa21c5a6620170296d9ee1eb777ec..c7c6e1997daf2b01c569605ce3965c1948c59eb5 100644
--- a/aleksis/core/tasks.py
+++ b/aleksis/core/tasks.py
@@ -1,3 +1,4 @@
+import time
 from datetime import timedelta
 
 from django.conf import settings
@@ -55,3 +56,21 @@ def clear_oauth_tokens():
 def send_notifications():
     """Send due notifications to users."""
     _send_due_notifications()
+
+
+@app.task
+def send_notification_for_done_task(task_id):
+    """Send a notification for a done task."""
+    from aleksis.core.models import TaskUserAssignment
+
+    # Wait five seconds to ensure that the client has received the final status
+    time.sleep(5)
+
+    try:
+        assignment = TaskUserAssignment.objects.get(task_result__task_id=task_id)
+    except TaskUserAssignment.DoesNotExist:
+        # No foreground task
+        return
+
+    if not assignment.result_fetched:
+        assignment.create_notification()
diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html
index 3158986ac5ed921244b43663e64c2fe9d728cd32..582a709d2f3c0a6935ebb218de7ae117336fa7bc 100644
--- a/aleksis/core/templates/core/base.html
+++ b/aleksis/core/templates/core/base.html
@@ -239,6 +239,8 @@
 {% include_js "materialize" %}
 {% include_js "sortablejs" %}
 {% include_js "jquery-sortablejs" %}
+{% url "searchbar_snippets" as search_snippets_url %}
+{{ search_snippets_url|json_script:"search-snippet-url" }}
 <script type="text/javascript" src="{% static 'js/search.js' %}"></script>
 <script type="text/javascript" src="{% static 'js/main.js' %}"></script>
 </body>
diff --git a/aleksis/core/templates/core/base_simple_print.html b/aleksis/core/templates/core/base_simple_print.html
new file mode 100644
index 0000000000000000000000000000000000000000..6e66e28983679acebdd2df0993ae7b5fdf64e621
--- /dev/null
+++ b/aleksis/core/templates/core/base_simple_print.html
@@ -0,0 +1,52 @@
+{% load static i18n any_js sass_tags %}
+{% get_current_language as LANGUAGE_CODE %}
+
+<!DOCTYPE html>
+<html lang="{{ LANGUAGE_CODE }}">
+<head>
+  {% include "core/partials/meta.html" %}
+
+  <title>
+    {% block no_browser_title %}
+      {% block browser_title %}{% endblock %} —
+    {% endblock %}
+    {{ SITE_PREFERENCES.general__title }}
+  </title>
+
+  {% include_css "material-design-icons" %}
+  {% include_css "Roboto100" %}
+  {% include_css "Roboto300" %}
+  {% include_css "Roboto400" %}
+  {% include_css "Roboto500" %}
+  {% include_css "Roboto700" %}
+  {% include_css "Roboto900" %}
+  {% include_css "paper-css" %}
+  <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}"/>
+  <link rel="stylesheet" href="{% static "print-simple.css" %}"/>
+
+  {% block size %}
+    <style>
+      @page {
+        size: {{ width }}mm {{ height }}mm;
+      }
+
+      @media print {
+        html, body {
+          width: {{ width }}mm;
+        }
+      }
+
+      .sheet {
+        width: {{ width }}mm;
+        height: {{ height|add:-1 }}.83mm;
+      } 
+    </style>
+  {% endblock %}
+
+  {% block extra_head %}{% endblock %}
+</head>
+
+<body>
+{% block content %}{% endblock %}
+</body>
+</html>
diff --git a/aleksis/core/templates/core/pages/about.html b/aleksis/core/templates/core/pages/about.html
index 6c44d90cbfe51df457f2bd09f600dfb676b7b97d..3489a50e2395309b078a0c1b2fa053b6a728fac6 100644
--- a/aleksis/core/templates/core/pages/about.html
+++ b/aleksis/core/templates/core/pages/about.html
@@ -1,5 +1,5 @@
 {# -*- engine:django -*- #}
-{% extends "core/base.html" %}
+{% extends "core/vue_base.html" %}
 {% load i18n %}
 
 
@@ -7,121 +7,5 @@
 {% block page_title %}{% blocktrans %}AlekSIS® – The Free School Information System{% endblocktrans %}{% endblock %}
 
 {% block content %}
-
-  <div class="row">
-    <div class="col s12">
-      <div class="card">
-        <div class="card-content">
-          <span class="card-title">{% blocktrans %}About AlekSIS{% endblocktrans %}</span>
-          <p>
-            {% blocktrans %}
-              This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used
-              to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and
-              can be used by anyone.
-            {% endblocktrans %}
-          </p>
-          <p>
-            {% blocktrans %}
-              AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V.
-            {% endblocktrans %}
-          </p>
-        </div>
-        <div class="card-action">
-          <a class="" href="https://aleksis.org/">{% trans "Website of AlekSIS" %}</a>
-          <a class="" href="https://edugit.org/AlekSIS/">{% trans "Source code" %}</a>
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="row">
-    <div class="col s12">
-      <div class="card">
-        <div class="card-content">
-          <span class="card-title">{% trans "Licence information" %}</span>
-          <p>
-            {% blocktrans %}
-              The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence
-              information from third-party apps, if installed, refer to the respective components below. The
-              licences are marked like this:
-            {% endblocktrans %}
-          </p>
-          <br/>
-          <p>
-            <span class="chip green white-text">{% trans "Free/Open Source Licence" %}</span>
-            <span class="chip orange white-text">{% trans "Other Licence" %}</span>
-          </p>
-        </div>
-        <div class="card-action">
-          <a href="https://eupl.eu">{% trans "Full licence text" %}</a>
-          <a href="https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers">{% trans "More information about the EUPL" %}</a>
-        </div>
-      </div>
-    </div>
-  </div>
-
-  <div class="row">
-    {% for app_config in app_configs %}
-      <div class="col s12 m12 l6">
-        <div class="card " id="{{ app_config.name }}">
-          <div class="card-content">
-            {% if app_config.get_licence.1.isFsfLibre %}
-              <span class="chip green white-text right">Free Software</span>
-            {% elif app_config.get_licence.1.isOsiApproved %}
-              <span class="chip green white-text right">Open Source</span>
-            {% endif %}
-
-            <span class="card-title">{{ app_config.get_name }} <small>{{ app_config.get_version }}</small></span>
-
-            {% if app_config.get_copyright %}
-              <p>
-                {% for holder in app_config.get_copyright %}
-                  Copyright © {{ holder.0 }}
-
-                  {% if holder.2 %}
-                    <a href="mailto:{{ holder.2 }}">{{ holder.1 }}</a>
-                  {% else %}
-                    {{ holder.1 }}
-                  {% endif %}
-
-                  <br/>
-                {% endfor %}
-              </p>
-              <br/>
-            {% endif %}
-
-            {% if app_config.get_licence %}
-              {% with licence=app_config.get_licence %}
-                <p>
-                  {% blocktrans with licence=licence.0 %}
-                    This app is licenced under {{ licence }}.
-                  {% endblocktrans %}
-                </p>
-                <br/>
-                <p>
-                  {% for l in licence.2 %}
-                    <a class="chip white-text {% if l.isOsiApproved or l.isFsfLibre %}green{% else %}orange{% endif %}"
-                       href="{{ l.url }}">
-                      {{ l.name }}
-                    </a>
-                  {% endfor %}
-                </p>
-              {% endwith %}
-            {% endif %}
-          </div>
-          {% if app_config.get_urls %}
-            <div class="card-action">
-              {% for url_name, url in app_config.get_urls.items %}
-                <a href="{{ url }}">{{ url_name }}</a>
-              {% endfor %}
-            </div>
-          {% endif %}
-        </div>
-      </div>
-      {% if forloop.counter|divisibleby:2 %}
-        </div>
-        <div class="row">
-      {% endif %}
-    {% endfor %}
-    </div>
-
+  <router-view></router-view>
 {% endblock %}
diff --git a/aleksis/core/templates/core/pages/progress.html b/aleksis/core/templates/core/pages/progress.html
index dc12869c8130a26bf2d5d96f1d30e8a83444bd61..82dc6506058e0b0e7da785a0161c1f45585634b7 100644
--- a/aleksis/core/templates/core/pages/progress.html
+++ b/aleksis/core/templates/core/pages/progress.html
@@ -1,63 +1,10 @@
-{% extends "core/base.html" %}
+{% extends "core/vue_base.html" %}
 {% load i18n static %}
 
 {% block browser_title %}
-  {{ title }}
-{% endblock %}
-{% block page_title %}
-  {{ title }}
+  {% trans "Progress" %}
 {% endblock %}
 
 {% block content %}
-
-  <div class="container">
-    <div class="row">
-      <div class="progress center">
-        <div class="indeterminate" style="width: 0;" id="progress-bar"></div>
-      </div>
-      <h6 class="center">
-        {{ progress.title }}
-      </h6>
-    </div>
-    <div class="row">
-      <noscript>
-        <div class="alert warning">
-          <p>
-            <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>
-            {% blocktrans %}
-              Without activated JavaScript the progress status can't be updated.
-            {% endblocktrans %}
-          </p>
-        </div>
-      </noscript>
-
-      <div id="messages"></div>
-
-      <div id="result-box" style="display: none;">
-        <div class="alert" id="result-alert">
-          <div>
-            <i class="material-icons iconify left" id="result-icon" data-icon="mdi:check-circle-outline"></i>
-            <p id="result-text"></p>
-          </div>
-        </div>
-
-        {% url "index" as index_url %}
-        <a class="btn waves-effect waves-light" href="{{ back_url|default:index_url }}">
-          <i class="material-icons iconify left" data-icon="mdi:arrow-left"></i>
-          {% trans "Go back" %}
-        </a>
-        {% if additional_button %}
-          <a class="btn waves-effect waves-light" href="{{ additional_button.href }}" id="result-button" style="display: none;">
-            <i class="material-icons iconify left" data-icon="{{ additional_button.icon|default:"" }}"></i>
-            {{ additional_button.caption }}
-          </a>
-        {% endif %}
-      </div>
-    </div>
-  </div>
-
-  {{ progress|json_script:"progress_options" }}
-  <script src="{% static "js/helper.js" %}"></script>
-  <script src="{% static "celery_progress/celery_progress.js" %}"></script>
-  <script src="{% static "js/progress.js" %}"></script>
+  <router-view></router-view>
 {% endblock %}
diff --git a/aleksis/core/templates/core/pages/system_status.html b/aleksis/core/templates/core/pages/system_status.html
index 4eee117807c2b2a45a1271e1f4aa9673189d105d..7f0e7bff0840029b500620662cd4681ac6bddded 100644
--- a/aleksis/core/templates/core/pages/system_status.html
+++ b/aleksis/core/templates/core/pages/system_status.html
@@ -22,7 +22,7 @@
             <p class="flow-text">{% blocktrans %}Maintenance mode enabled{% endblocktrans %}</p>
             <p class="grey-text">
               {% blocktrans %}
-                Only admin and visitors from internal IPs can access thesite.
+                Only admin and visitors from internal IPs can access the site.
               {% endblocktrans %}
             </p>
           </div>
diff --git a/aleksis/core/templates/core/person/full.html b/aleksis/core/templates/core/person/full.html
index 9a58951118e4bdafe3278cb7e4280e455fa99943..d295cab1705919b3f70d8d5f7ce32f068ac8e886 100644
--- a/aleksis/core/templates/core/person/full.html
+++ b/aleksis/core/templates/core/person/full.html
@@ -241,18 +241,36 @@
     {% endif %}
 
     {% has_perm 'core.view_person_groups_rule' user person as can_view_groups %}
-    {% if can_view_groups and groups %}
+    {% if can_view_groups %}
       <div class="col s12 m6 l4">
-        <h2>{% blocktrans %}Groups{% endblocktrans %}</h2>
-        <div class="card-panel">
-          <div class="collection">
-            {% for group in groups %}
-              <a href="{{ group.get_absolute_url }}" class="collection-item">
-                {{ group.name }} ({{ group.school_term }})
-              </a>
-            {% endfor %}
+        {% if groups.count  %}
+          <div>
+            <h2>{% blocktrans %}Groups{% endblocktrans %}</h2>
+            <div class="card-panel">
+              <div class="collection">
+                {% for group in groups %}
+                  <a href="{{ group.get_absolute_url }}" class="collection-item">
+                    {{ group.name }} ({{ group.school_term }})
+                  </a>
+                {% endfor %}
+              </div>
+            </div>
           </div>
-        </div>
+        {% endif %}
+        {% if person.owner_of_recursive.count %}
+          <div>
+            <h2>{% blocktrans %}Group ownership{% endblocktrans %}</h2>
+            <div class="card-panel">
+              <div class="collection">
+                {% for group in person.owner_of_recursive.all %}
+                  <a href="{{ group.get_absolute_url }}" class="collection-item">
+                    {{ group.name }} ({{ group.school_term }})
+                </a>
+                {% endfor %}
+              </div>
+            </div>
+          </div>
+        {% endif %}
       </div>
     {% endif %}
   </div>
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index 3d8f6a7bf10553c6aed4d2dde2fbe1740b7651fc..85abd8e79d4752aa79e771c5d251dfb1fe450f53 100644
--- a/aleksis/core/templates/core/vue_base.html
+++ b/aleksis/core/templates/core/vue_base.html
@@ -19,17 +19,12 @@
   </title>
 
   {# CSS #}
-  {# FIXME ↓ #}
-  {#  {% include_css "material-design-icons" %}#}
   {% include_css "Roboto100" %}
   {% include_css "Roboto300" %}
   {% include_css "Roboto400" %}
   {% include_css "Roboto500" %}
   {% include_css "Roboto700" %}
   {% include_css "Roboto900" %}
-  {#  <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}">#}
-
-  <!-- FIXME: Find a way to use SCSS!!! -->
 
   {# Add JS URL resolver #}
   <script src="{% url "js_reverse" %}" type="text/javascript"></script>
@@ -61,9 +56,6 @@
   <script type="text/javascript" src="{% url 'config.js' %}"></script>
   {% include_js "iconify" %}
 
-  {# Include jQuery early to provide $(document).ready #}
-  {% include_js "jQuery" %}
-
   {% block extra_head %}{% endblock %}
 </head>
 <body {% if no_menu %}class="without-menu"{% endif %}>
@@ -82,7 +74,7 @@
           {% has_perm 'core.search_rule' user as search %}
           {% if search %}
             <v-list-item class="search">
-              <sidenav-search action="{% url "haystack_search" %}" placeholder="{% trans "Search" %}"></sidenav-search>
+              <sidenav-search action="{% url "haystack_search" %}"></sidenav-search>
             </v-list-item>
           {% endif %}
           {% include "core/partials/vue_sidenav.html" %}
@@ -168,6 +160,8 @@
       </v-container>
     </v-main>
 
+    <celery-progress-bottom />
+
     <v-footer app absolute inset dark class="pa-0 d-flex" color="primary lighten-1">
       <v-card
         flat
diff --git a/aleksis/core/templates/search/searchbar_snippet.html b/aleksis/core/templates/search/searchbar_snippet.html
index d2a401c4f874d6a2c2a71c5b122eb3879cd83d57..d0ec278c2b232f701e6231e337c14c10c0096839 100644
--- a/aleksis/core/templates/search/searchbar_snippet.html
+++ b/aleksis/core/templates/search/searchbar_snippet.html
@@ -1,4 +1,4 @@
 <a href="{{ result.object.get_absolute_url|default:"#" }}" class="collection-item search-item">
   {{ result.object }}
-  <i class="material-icons secondary-content search-result-icon" data-icon="mdi:{{ result.object.icon_ }}"></i>
+  <i class="material-icons secondary-content search-result-icon iconify" data-icon="mdi:{{ result.object.icon_ }}"></i>
 </a>
diff --git a/aleksis/core/templates/templated_email/email.css b/aleksis/core/templates/templated_email/email.css
index 8cd112624c0c0b78f663027841895f4e07ce608b..465da3dd073bf681eb3146a077ea17d46f643fca 100644
--- a/aleksis/core/templates/templated_email/email.css
+++ b/aleksis/core/templates/templated_email/email.css
@@ -1,41 +1,45 @@
 body {
-    line-height: 1.5;
-    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
-    font-weight: normal;
-    color: rgba(0, 0, 0, 0.87);
-    display: flex;
-    justify-content: center;
-    align-items: center;
+  line-height: 1.5;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+    Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  font-weight: normal;
+  color: rgba(0, 0, 0, 0.87);
+  display: flex;
+  justify-content: center;
+  align-items: center;
 }
 
-table, tr {
-    width: 100%;
+table,
+tr {
+  width: 100%;
 }
 
 .main {
-    max-width: 700px;
-    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
-    -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
-    -webkit-transition: -webkit-box-shadow .25s;
-    transition: -webkit-box-shadow .25s;
-    transition: box-shadow .25s;
-    transition: box-shadow .25s, -webkit-box-shadow .25s;
-    border-radius: 2px;
-    background-color: #fff;
-    margin: 30px;
-    padding: 20px;
+  max-width: 700px;
+  box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
+    0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
+  -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
+    0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
+  -webkit-transition: -webkit-box-shadow 0.25s;
+  transition: -webkit-box-shadow 0.25s;
+  transition: box-shadow 0.25s;
+  transition: box-shadow 0.25s, -webkit-box-shadow 0.25s;
+  border-radius: 2px;
+  background-color: #fff;
+  margin: 30px;
+  padding: 20px;
 }
 
 .first th {
-    border-bottom: 1px solid;
+  border-bottom: 1px solid;
 }
 
-
-td, th {
-    padding-left: 5px;
-    padding-right: 5px;
+td,
+th {
+  padding-left: 5px;
+  padding-right: 5px;
 }
 
 .align-center {
-    text-align: center;
+  text-align: center;
 }
diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py
index 5b3898dd835506ade932ebccf30e4cf0de15486e..49e359872784773836558c13081ae1290b4477c5 100644
--- a/aleksis/core/util/apps.py
+++ b/aleksis/core/util/apps.py
@@ -128,12 +128,28 @@ class AppConfig(django.apps.AppConfig):
             # We could not find a valid licence
             return ("Unknown", [default_dict])
 
+    @classmethod
+    def get_licence_dict(cls):
+        """Get licence information of application package."""
+        licence = cls.get_licence()
+        return {
+            "verbose_name": licence[0],
+            "flags": licence[1],
+            "licences": licence[2],
+        }
+
     @classmethod
     def get_urls(cls):
         """Get list of URLs for this application package."""
         return getattr(cls, "urls", {})
         # TODO Try getting from distribution if not set
 
+    @classmethod
+    def get_urls_dict(cls):
+        """Get list of URLs for this application package."""
+        urls = cls.get_urls()
+        return [{"name": key, "url": value} for key, value in urls.items()]
+
     @classmethod
     def get_copyright(cls) -> Sequence[tuple[str, str, str]]:
         """Get copyright information tuples for application package."""
@@ -155,6 +171,12 @@ class AppConfig(django.apps.AppConfig):
         return copyrights_processed
         # TODO Try getting from distribution if not set
 
+    @classmethod
+    def get_copyright_dicts(cls):
+        """Get copyright information dictionaries for application package."""
+        infos = cls.get_copyright()
+        return [{"years": info[0], "name": info[1], "email": info[2]} for info in infos]
+
     def preference_updated(
         self,
         sender: Any,
diff --git a/aleksis/core/util/celery_progress.py b/aleksis/core/util/celery_progress.py
index 91b5b7168f215da509c71806f137483a422872d3..d377d9d334c0243af210cc68e3474aeaea748b2e 100644
--- a/aleksis/core/util/celery_progress.py
+++ b/aleksis/core/util/celery_progress.py
@@ -5,12 +5,13 @@ from typing import Callable, Generator, Iterable, Optional, Sequence, Union
 from django.apps import apps
 from django.contrib import messages
 from django.http import HttpRequest
-from django.shortcuts import render
+from django.shortcuts import redirect
 
 from celery.result import AsyncResult
 from celery_progress.backend import PROGRESS_STATE, AbstractProgressRecorder
 
 from ..celery import app
+from ..tasks import send_notification_for_done_task
 
 
 class ProgressRecorder(AbstractProgressRecorder):
@@ -156,6 +157,11 @@ def recorded_task(orig: Optional[Callable] = None, **kwargs) -> Union[Callable,
         def _inject_recorder(task, *args, **kwargs):
             recorder = ProgressRecorder(task)
             orig(*args, **kwargs, recorder=recorder)
+
+            # Start notification task to ensure
+            # that the user is informed about the result in any case
+            send_notification_for_done_task.delay(task.request.id)
+
             return recorder._messages
 
         # Force bind to True because _inject_recorder needs the Task object
@@ -203,22 +209,15 @@ def render_progress_page(
     TaskUserAssignment = apps.get_model("core", "TaskUserAssignment")
     assignment = TaskUserAssignment.create_for_task_id(task_result.task_id, request.user)
 
-    # Prepare context for progress page
-    context["title"] = title
-    context["back_url"] = back_url
-    context["progress"] = {
-        "task_id": task_result.task_id,
-        "title": progress_title,
-        "success": success_message,
-        "error": error_message,
-        "redirect_on_success": redirect_on_success_url,
-    }
-
-    if button_url and button_title:
-        context["additional_button"] = {
-            "href": button_url,
-            "caption": button_title,
-            "icon": button_icon,
-        }
-
-    return render(request, "core/pages/progress.html", context)
+    assignment.title = title
+    assignment.back_url = back_url or ""
+    assignment.progress_title = progress_title or ""
+    assignment.error_message = error_message or ""
+    assignment.success_message = success_message or ""
+    assignment.redirect_on_success_url = redirect_on_success_url or ""
+    assignment.additional_button_title = button_title or ""
+    assignment.additional_button_url = button_url or ""
+    assignment.additional_button_icon = button_icon or ""
+    assignment.save()
+
+    return redirect("task_status", task_id=task_result.task_id)
diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py
index a261017b3b2bb22decea1cc517b414858b14a68c..66b5e1a9fdf58d4630651e1795167df001f42567 100644
--- a/aleksis/core/util/pdf.py
+++ b/aleksis/core/util/pdf.py
@@ -1,8 +1,9 @@
+import base64
 import os
 import subprocess  # noqa
 from datetime import timedelta
 from tempfile import TemporaryDirectory
-from typing import Optional, Tuple, Union
+from typing import Callable, Optional, Tuple, Union
 from urllib.parse import urljoin
 
 from django.conf import settings
@@ -19,6 +20,7 @@ from django.utils.translation import gettext as _
 
 from celery.result import AsyncResult
 from celery_progress.backend import ProgressRecorder
+from selenium import webdriver
 
 from aleksis.core.celery import app
 from aleksis.core.models import PDFFile
@@ -26,6 +28,29 @@ from aleksis.core.util.celery_progress import recorded_task, render_progress_pag
 from aleksis.core.util.core_helpers import process_custom_context_processors
 
 
+def _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang):
+    """Generate a PDF file from a HTML file."""
+    chrome_options = webdriver.ChromeOptions()
+    chrome_options.add_argument("--kiosk-printing")
+    chrome_options.add_argument("--headless")
+    chrome_options.add_argument("--no-sandbox")
+    chrome_options.add_argument("--disable-gpu")
+    chrome_options.add_argument("--disable-dev-shm-usage")
+    chrome_options.add_argument("--disable-setuid-sandbox")
+    chrome_options.add_argument("--dbus-stub")
+    chrome_options.add_argument("--temp-profile")
+    chrome_options.add_argument(f"--lang={lang}")
+
+    driver = webdriver.Chrome(options=chrome_options)
+    driver.get(html_url)
+    pdf = driver.execute_cdp_cmd(
+        "Page.printToPDF", {"printBackground": True, "preferCSSPageSize": True}
+    )
+    driver.close()
+    with open(pdf_path, "wb") as f:
+        f.write(base64.b64decode(pdf["data"]))
+
+
 @recorded_task
 def generate_pdf(
     file_pk: int, html_url: str, recorder: ProgressRecorder, lang: Optional[str] = None
@@ -40,26 +65,7 @@ def generate_pdf(
         pdf_path = os.path.join(temp_dir, "print.pdf")
         lang = lang or get_language()
 
-        # Run PDF generation using a headless Chromium
-        cmd = [
-            "chromium",
-            "--headless",
-            "--no-sandbox",
-            "--run-all-compositor-stages-before-draw",
-            "--temp-profile",
-            "--disable-dev-shm-usage",
-            "--disable-gpu",
-            "--disable-setuid-sandbox",
-            "--dbus-stub",
-            f"--home-dir={temp_dir}",
-            f"--lang={lang}",
-            f"--print-to-pdf={pdf_path}",
-            html_url,
-        ]
-        res = subprocess.run(cmd)  # noqa
-
-        # Let the task fail on a non-success return code
-        res.check_returncode()
+        _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang)
 
         # Upload PDF file to media storage
         with open(pdf_path, "rb") as f:
@@ -69,10 +75,8 @@ def generate_pdf(
     recorder.set_progress(1, 1)
 
 
-def generate_pdf_from_template(
-    template_name: str, context: Optional[dict] = None, request: Optional[HttpRequest] = None
-) -> Tuple[PDFFile, AsyncResult]:
-    """Start a PDF generation task and return the matching file object and Celery result."""
+def process_context_for_pdf(context: Optional[dict] = None, request: Optional[HttpRequest] = None):
+    context = context or {}
     if not request:
         processed_context = process_custom_context_processors(
             settings.NON_REQUEST_CONTEXT_PROCESSORS
@@ -80,11 +84,20 @@ def generate_pdf_from_template(
         processed_context.update(context)
     else:
         processed_context = context
-    html_template = render_to_string(template_name, processed_context, request)
+    return processed_context
 
-    file_object = PDFFile.objects.create(
-        html_file=ContentFile(html_template.encode(), name="source.html")
-    )
+
+def generate_pdf_from_html(
+    html: str, request: Optional[HttpRequest] = None, file_object: Optional[PDFFile] = None
+) -> Tuple[PDFFile, AsyncResult]:
+    """Start a PDF generation task and return the matching file object and Celery result."""
+    html_file = ContentFile(html.encode(), name="source.html")
+
+    # In some cases, the file object is already created (to get a redirect URL for the PDF)
+    if not file_object:
+        file_object = PDFFile.objects.create()
+    file_object.html_file = html_file
+    file_object.save()
 
     # As this method may be run in background and there is no request available,
     # we have to use a predefined URL from settings then
@@ -98,6 +111,24 @@ def generate_pdf_from_template(
     return file_object, result
 
 
+def generate_pdf_from_template(
+    template_name: str,
+    context: Optional[dict] = None,
+    request: Optional[HttpRequest] = None,
+    render_method: Optional[Callable] = None,
+    file_object: Optional[PDFFile] = None,
+) -> Tuple[PDFFile, AsyncResult]:
+    """Start a PDF generation task and return the matching file object and Celery result."""
+    processed_context = process_context_for_pdf(context, request)
+
+    if render_method:
+        html_template = render_method(processed_context, request)
+    else:
+        html_template = render_to_string(template_name, processed_context, request)
+
+    return generate_pdf_from_html(html_template, request, file_object=file_object)
+
+
 def render_pdf(
     request: Union[HttpRequest, None], template_name: str, context: dict = None
 ) -> HttpResponse:
@@ -123,7 +154,7 @@ def render_pdf(
         back_url=context.get("back_url", reverse("index")),
         button_title=_("Download PDF"),
         button_url=redirect_url,
-        button_icon="picture_as_pdf",
+        button_icon="mdi-file-pdf-box",
     )
 
 
diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py
index 5ba4271c08a4244ba5f359e9c46e01eff6c6d112..9996fa4655c75d525d7d6a305d3f1fc839323889 100644
--- a/aleksis/core/util/predicates.py
+++ b/aleksis/core/util/predicates.py
@@ -160,3 +160,9 @@ def has_activated_2fa(user: User) -> bool:
 def is_assigned_to_current_person(user: User, obj: Model) -> bool:
     """Check if the object is assigned to the current person."""
     return getattr(obj, "person", None) == user.person
+
+
+@predicate
+def is_own_celery_task(user: User, obj: Model) -> bool:
+    """Check if the celery task is owned by the current user."""
+    return obj.user == user
diff --git a/aleksis/core/util/search.py b/aleksis/core/util/search.py
index 34c736f9e13b55d6ac277d41b76a374efa2b43d1..39cdf4ef35ce7b75819ae61341c7a643a84b20d5 100644
--- a/aleksis/core/util/search.py
+++ b/aleksis/core/util/search.py
@@ -8,7 +8,7 @@ Indexable = indexes.Indexable  # noqa
 class SearchIndex(BaseSearchIndex):
     """Base class for search indexes on AlekSIS models.
 
-    It provides a default document field caleld text and exects
+    It provides a default document field called text and exects
     the related model in the model attribute.
     """
 
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 9296caff18f638c1dccd7ec546681d2a081e5f8b..bbbe7cc1871735e006c64f0fd8726d39312ec6a1 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -40,7 +40,6 @@ from allauth.account.utils import has_verified_email, send_email_confirmation
 from allauth.account.views import PasswordChangeView, PasswordResetView, SignupView
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialAccount
-from celery_progress.views import get_progress
 from django_celery_results.models import TaskResult
 from django_filters.views import FilterView
 from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView
@@ -52,7 +51,7 @@ from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 from health_check.views import MainView
-from invitations.views import SendInvite, accept_invitation
+from invitations.views import SendInvite
 from oauth2_provider.exceptions import OAuthToolkitError
 from oauth2_provider.models import get_application_model
 from oauth2_provider.views import AuthorizationView
@@ -1105,12 +1104,7 @@ class EnterInvitationCode(FormView):
             and not PersonInvitation.objects.get(key=code).accepted
             and not PersonInvitation.objects.get(key=code).key_expired()
         ):
-            invitation = PersonInvitation.objects.get(key=code)
-            # Mark invitation as accepted and redirect to signup
-            accept_invitation(
-                invitation=invitation, request=self.request, signal_sender=self.request.user
-            )
-            self.request.session["invitation_code_entered"] = True
+            self.request.session["invitation_code"] = code
             return redirect("account_signup")
         return redirect("invitations:accept-invite", code)
 
@@ -1338,17 +1332,15 @@ class RedirectToPDFFile(SingleObjectMixin, View):
         return redirect(file_object.file.url)
 
 
-class CeleryProgressView(View):
+class CeleryProgressView(PermissionRequiredMixin, DetailView):
     """Wrap celery-progress view to check permissions before."""
 
-    def get(self, request: HttpRequest, task_id: str, *args, **kwargs) -> HttpResponse:
-        if request.user.is_anonymous:
-            raise Http404(_("The requested task does not exist or is not accessible"))
-        if not TaskUserAssignment.objects.filter(
-            task_result__task_id=task_id, user=request.user
-        ).exists():
-            raise Http404(_("The requested task does not exist or is not accessible"))
-        return get_progress(request, task_id, *args, **kwargs)
+    template_name = "core/pages/progress.html"
+    permission_required = "core.view_progress_rule"
+
+    def get_object(self, queryset=None):
+        task_id = self.kwargs.get("task_id")
+        return TaskUserAssignment.objects.get(task_result__task_id=task_id)
 
 
 class CustomPasswordChangeView(LoginRequiredMixin, PermissionRequiredMixin, PasswordChangeView):
@@ -1434,7 +1426,7 @@ class AccountRegisterView(SignupView):
         if (
             not request.user.has_perm("core.can_register")
             and not request.session.get("account_verified_email")
-            and not request.session.get("invitation_code_entered")
+            and not request.session.get("invitation_code")
         ):
             raise PermissionDenied()
         return super(AccountRegisterView, self).dispatch(request, *args, **kwargs)
diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js
index f33cc60252c0c11bfeacb4c304db7c396a9530ad..8f327cbe55a6dba6b7ae5f104a89f4da46ddf711 100644
--- a/aleksis/core/webpack.config.js
+++ b/aleksis/core/webpack.config.js
@@ -1,50 +1,58 @@
-const fs = require('fs');
-const path = require('path');
-const webpack = require('webpack');
-const BundleTracker = require('webpack-bundle-tracker');
-const { VueLoaderPlugin } = require('vue-loader');
+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')),
+  entry: JSON.parse(fs.readFileSync("./webpack-entrypoints.json")),
   output: {
-    path: path.resolve('./webpack_bundles/'),
+    path: path.resolve("./webpack_bundles/"),
     filename: "[name]-[hash].js",
     chunkFilename: "[id]-[chunkhash].js",
   },
   plugins: [
-    new BundleTracker({filename: './webpack-stats.json'}),
+    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',
+          loader: "vue-loader",
           options: {
             transpileOptions: {
               transforms: {
-                dangerousTaggedTemplateString: true
-              }
-            }
-          }
+                dangerousTaggedTemplateString: true,
+              },
+            },
+          },
         },
       },
       {
         test: /\.(css)$/,
-        use: ['vue-style-loader', 'css-loader'],
+        use: ["vue-style-loader", "css-loader"],
       },
       {
         test: /\.scss$/,
         use: [
-          'vue-style-loader',
-          'css-loader',
+          "vue-style-loader",
+          "css-loader",
           {
-            loader: 'sass-loader',
+            loader: "sass-loader",
             options: {
               sassOptions: {
-                indentedSyntax: false
+                indentedSyntax: false,
               },
             },
           },
@@ -53,7 +61,7 @@ module.exports = {
       {
         test: /\.(graphql|gql)$/,
         exclude: /node_modules/,
-        loader: 'graphql-tag/loader',
+        loader: "graphql-tag/loader",
       },
     ],
   },
@@ -75,15 +83,15 @@ module.exports = {
 
             // npm package names are URL-safe, but some servers don't like @ symbols
             return `npm.${packageName.replace("@", "")}`;
-          }
-        }
-      }
-    }
+          },
+        },
+      },
+    },
   },
   resolve: {
-    modules: [path.resolve('./node_modules')],
+    modules: [path.resolve("./node_modules")],
     alias: {
-      'vue$': 'vue/dist/vue.esm.js'
-    }
+      vue$: "vue/dist/vue.esm.js",
+    },
   },
-}
+};
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index 3f350d5f9199ac265b6be906ad284a20705a5bf9..ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -53,6 +53,7 @@ Install some packages from the Debian package system.
                yarnpkg \
                python3-virtualenv \
                chromium \
+               chromium-driver \
                redis-server \
                postgresql \
                locales-all \
diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst
index 75cf677a7f43703dc5e6a389e97be28fcf813a02..1b82cf5e35c78585ee01b101120ceaff8fa19305 100644
--- a/docs/dev/01_setup.rst
+++ b/docs/dev/01_setup.rst
@@ -47,7 +47,7 @@ Install native dependencies
 
 Some system libraries are required to install AlekSIS. On Debian, for example, this would be done with::
 
-  sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium
+  sudo apt install build-essential libpq-dev libpq5 libssl-dev python3-dev python3-pip python3-venv yarnpkg gettext chromium chromium-driver
 
 Get Poetry
 ----------
diff --git a/pyproject.toml b/pyproject.toml
index b35aa43fcd6127465359407aca3c7c25002c759d..ea5efd732d8efcb32f9c4ff28a71bf6894b873b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,8 @@ authors = [
     "Hangzhi Yu <yuha@katharineum.de>",
     "Lloyd Meins <meinsll@katharineum.de>",
     "magicfelix <felix@felix-zauberer.de>",
-    "Benedict Suska <benedict.suska@teckids.de>"
+    "Benedict Suska <benedict.suska@teckids.de>",
+    "Lukas Weichelt <lukas.weichelt@teckids.de>"
 ]
 maintainers = [
     "Jonathan Weth <dev@jonathanweth.de>",
@@ -60,8 +61,8 @@ django-any-js = "^1.1"
 django-menu-generator-ng = "^1.2.3"
 django-tables2 = "^2.1"
 django-phonenumber-field = {version = "^6.1", extras = ["phonenumbers"]}
-django-sass-processor = "1.0"
-libsass = "^0.21.0"
+django-sass-processor = "1.2.2"
+libsass = "^0.22.0"
 colour = "^0.1.5"
 dynaconf = {version = "^3.1", extras = ["yaml", "toml", "ini"]}
 django-auth-ldap = { version = "^4.0", optional = true }
@@ -69,7 +70,7 @@ django-maintenance-mode = "^0.16.3"
 django-ipware = "^4.0"
 django-impersonate = "^1.4"
 psycopg2 = "^2.8"
-django_select2 = "^7.1"
+django_select2 = "^8.0"
 django-two-factor-auth = { version = "^1.14.0", extras = [ "yubikey", "phonenumbers", "call", "sms" ] }
 django-yarnpkg = "^6.0"
 django-material = "^1.6.0"
@@ -87,7 +88,7 @@ django-celery-beat = "^2.2.0"
 django-celery-email = "^3.0.0"
 django-jsonstore = "^0.5.0"
 django-polymorphic = "^3.0.0"
-django-colorfield = "^0.7.0"
+django-colorfield = "^0.8.0"
 django-bleach = "^3.0.0"
 django-guardian = "^2.2.0"
 rules = "^3.0"
@@ -130,6 +131,7 @@ django-iconify = "^0.3"
 customidenticon = "^0.1.5"
 graphene-django = "^3.0.0"
 django-webpack-loader = "^1.6.0"
+selenium = "^4.4.3"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
diff --git a/tox.ini b/tox.ini
index 9b265e53f8a154838b5af4fd2a4d3475e9c69d40..ff7a8c4aa3f0d97c2224ac9e4998fdaa246265c7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,6 +6,7 @@ envlist = py37,py38,py39
 [testenv]
 whitelist_externals = poetry
 		      sudo
+                      node
 skip_install = true
 envdir = {toxworkdir}/globalenv
 commands_pre =
@@ -14,6 +15,8 @@ commands_pre =
      poetry run aleksis-admin collectstatic --no-input
 commands =
     poetry run pytest --cov=. {posargs} aleksis/
+setenv=
+    NODE_PATH=cache/node_modules/
 
 [testenv:selenium]
 setenv =
@@ -27,6 +30,8 @@ commands =
     poetry run black --check --diff aleksis/
     poetry run isort -c --diff --stdout aleksis/
     poetry run flake8 {posargs} aleksis/
+    node cache/node_modules/.bin/prettier --check .
+    node cache/node_modules/.bin/eslint aleksis/**/*/assets/**/*.{js,vue}
 
 [testenv:security]
 commands =
@@ -46,6 +51,7 @@ commands = poetry run make -C docs/ html {posargs}
 commands =
     poetry run isort aleksis/
     poetry run black aleksis/
+    node cache/node_modules/.bin/prettier --write .
 
 [testenv:makemessages]
 commands =