diff --git a/aleksis/core/assets/components/SmallContainer.vue b/aleksis/core/assets/components/SmallContainer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..01d198ef7ac58b7ac1cb1f630b93c50f02644a77
--- /dev/null
+++ b/aleksis/core/assets/components/SmallContainer.vue
@@ -0,0 +1,15 @@
+<template>
+  <v-row>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+    <v-col sm="12" md="10" lg="8" xl="6">
+      <slot></slot>
+    </v-col>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+  </v-row>
+</template>
+
+<script>
+export default {
+  name: "SmallContainer",
+};
+</script>
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
index 6de627139e71b404da4eea976fcd749db89cc1dd..144a2a7628e91bd60b060409e88c745d8b2683ee 100644
--- a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
@@ -1,86 +1,82 @@
 <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>
+  <small-container>
+    <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-if="progress.state === 'ERROR'"
+            v-for="(message, idx) in progress.messages"
             dense
-            type="error"
+            :type="message.tag"
             transition="slide-x-transition"
+            :key="idx"
           >
-            {{
-              progress.meta.errorMessage
-                ? progress.meta.errorMessage
-                : $t("celery_progress.error_message")
-            }}
+            {{ message.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')
-          "
+        </div>
+        <message-box
+          v-if="progress.state === 'ERROR'"
+          dense
+          type="error"
+          transition="slide-x-transition"
         >
-          <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>
+          {{
+            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>
+  </small-container>
 </template>
 
 <script>
@@ -88,10 +84,11 @@ import BackButton from "../BackButton.vue";
 import MessageBox from "../MessageBox.vue";
 import gqlCeleryProgress from "./celeryProgress.graphql";
 import gqlCeleryProgressFetched from "./celeryProgressFetched.graphql";
+import SmallContainer from "../SmallContainer.vue";
 
 export default {
   name: "CeleryProgress",
-  components: { BackButton, MessageBox },
+  components: { SmallContainer, BackButton, MessageBox },
   apollo: {
     celeryProgressByTaskId: {
       query: gqlCeleryProgress,
diff --git a/aleksis/core/assets/components/pdf/DownloadPDF.vue b/aleksis/core/assets/components/pdf/DownloadPDF.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8438a01ee48c0e8b72cc14997cc5f5d0727b639d
--- /dev/null
+++ b/aleksis/core/assets/components/pdf/DownloadPDF.vue
@@ -0,0 +1,48 @@
+<template>
+  <small-container>
+    <v-card>
+      <v-card-title v-if="pdf">{{ $t("download_pdf.title") }}</v-card-title>
+      <v-card-text v-if="pdf" class="body-1">
+        {{ $t("download_pdf.notice") }}
+      </v-card-text>
+      <v-card-actions v-if="pdf">
+        <v-btn color="primary" text :href="pdf.file.url" download>
+          <v-icon left>mdi-download</v-icon>
+          {{ $t("download_pdf.download") }}
+        </v-btn>
+      </v-card-actions>
+      <v-skeleton-loader
+        type="article"
+        v-if="$apollo.queries.pdf.loading"
+      ></v-skeleton-loader>
+    </v-card>
+  </small-container>
+</template>
+
+<script>
+import gqlPdf from "./pdf.graphql";
+import SmallContainer from "../SmallContainer.vue";
+
+export default {
+  name: "DownloadPDF",
+  components: { SmallContainer },
+  apollo: {
+    pdf: {
+      query: gqlPdf,
+      variables() {
+        return {
+          id: this.$route.params.id,
+        };
+      },
+    },
+  },
+  watch: {
+    pdf(value) {
+      // Automatic redirect
+      if (value) {
+        window.location.href = value.file.url;
+      }
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/pdf/pdf.graphql b/aleksis/core/assets/components/pdf/pdf.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..aac3228d75c3c77ab131500b5e0d5e2ae1b8f503
--- /dev/null
+++ b/aleksis/core/assets/components/pdf/pdf.graphql
@@ -0,0 +1,7 @@
+query ($id: ID!) {
+  pdf: pdfById(id: $id) {
+    file {
+      url
+    }
+  }
+}
diff --git a/aleksis/core/assets/messages/de.json b/aleksis/core/assets/messages/de.json
index 435dd4f8a9618f0ac135d81ebf2c423b1e1a77d1..32b9368e9d2e93f99bd8680909c07a709ed6d64c 100644
--- a/aleksis/core/assets/messages/de.json
+++ b/aleksis/core/assets/messages/de.json
@@ -93,6 +93,11 @@
   "data_check": {
     "menu_title": "Datenprüfungen"
   },
+  "download_pdf": {
+    "title": "PDF-Datei wird heruntergeladen ...",
+    "download": "Herunterladen",
+    "notice": "Wenn der Download nicht automatisch beginnt, klicken Sie bitte auf den Button unten."
+  },
   "group": {
     "additional_field": {
       "menu_title": "Zusätzliche Felder",
diff --git a/aleksis/core/assets/messages/en.json b/aleksis/core/assets/messages/en.json
index 8da883db7a5ef7ff60028c5a20a319d9f5afad97..b2679b64a49563ff4a9491cfe891ef4d6ecfe6f5 100644
--- a/aleksis/core/assets/messages/en.json
+++ b/aleksis/core/assets/messages/en.json
@@ -82,6 +82,11 @@
     "running_tasks": "1 running task | {number} running tasks",
     "success_message": "The operation has been finished successfully."
   },
+  "download_pdf": {
+    "title": "Downloading PDF file ...",
+    "download": "Download",
+    "notice": "If the download does not start automatically, please click the button below."
+  },
   "dashboard": {
     "dashboard_widget": {
       "menu_title": "Dashboard Widgets",
diff --git a/aleksis/core/assets/routes.js b/aleksis/core/assets/routes.js
index cc869b32a1788d63f3795ca8180bd7d6ea07a0ad..24dcb93d146afaedd1b27a74f4750339b1ac7d93 100644
--- a/aleksis/core/assets/routes.js
+++ b/aleksis/core/assets/routes.js
@@ -733,8 +733,8 @@ const routes = [
     name: "core.testPdf",
   },
   {
-    path: "/pdfs/:pk(\\d+)/",
-    component: () => import("./components/LegacyBaseTemplate.vue"),
+    path: "/pdfs/:id",
+    component: () => import("./components/pdf/DownloadPDF.vue"),
     name: "core.redirectToPdfUrl",
   },
   {
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index fff6d2cfa71a70b30d2f92a39f782ffc151c6927..e2f348a7ac9becd40cbc64bae7194b6139eba305 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -4,7 +4,7 @@ import hmac
 import uuid
 from datetime import date, datetime, timedelta
 from typing import Any, Iterable, List, Optional, Sequence, Union
-from urllib.parse import urlparse
+from urllib.parse import urljoin, urlparse
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -1316,7 +1316,7 @@ class TaskUserAssignment(ExtensibleModel):
             # Task not yet finished
             return
 
-        link = reverse("task_status", args=[self.task_result.task_id])
+        link = urljoin(settings.BASE_URL, self.get_absolute_url())
 
         notification = Notification(
             sender=_("Background task"),
@@ -1329,6 +1329,9 @@ class TaskUserAssignment(ExtensibleModel):
         notification.save()
         return notification
 
+    def get_absolute_url(self) -> str:
+        return f"/celery_progress/{self.task_result.task_id}"
+
     class Meta:
         verbose_name = _("Task user assignment")
         verbose_name_plural = _("Task user assignments")
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index dd85daf36caeb501ae3e4d9bc4709149f8a13098..71543acf5ef33020da44bbd6266db692a02d123f 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -9,7 +9,7 @@ from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 
-from ..models import CustomMenu, Notification, Person, TaskUserAssignment
+from ..models import CustomMenu, Notification, PDFFile, 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
@@ -18,6 +18,7 @@ from .group import GroupType  # noqa
 from .installed_apps import AppType
 from .message import MessageType
 from .notification import MarkNotificationReadMutation, NotificationType
+from .pdf import PDFFileType
 from .permissions import GlobalPermissionType
 from .person import PersonMutation, PersonType
 from .school_term import SchoolTermType  # noqa
@@ -43,6 +44,8 @@ class Query(graphene.ObjectType):
     celery_progress_by_task_id = graphene.Field(CeleryProgressType, task_id=graphene.String())
     celery_progress_by_user = graphene.List(CeleryProgressType)
 
+    pdf_by_id = graphene.Field(PDFFileType, id=graphene.ID())
+
     search_snippets = graphene.List(
         SearchResultType, query=graphene.String(), limit=graphene.Int(required=False)
     )
@@ -111,6 +114,12 @@ class Query(graphene.ObjectType):
             if task.get_progress_with_meta()["complete"] is False
         ]
 
+    def resolve_pdf_by_id(root, info, id, **kwargs):  # noqa
+        pdf_file = PDFFile.objects.get(pk=id)
+        if has_person(info.context) and not info.context.user.person == pdf_file.person:
+            raise PermissionDenied()
+        return pdf_file
+
     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)
diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py
index 363c6b8ac849affcba2d9c1cbf9eefa74498121d..e927dadccaaab7b2bf39c2110286970222f70f5b 100644
--- a/aleksis/core/schema/base.py
+++ b/aleksis/core/schema/base.py
@@ -1,3 +1,4 @@
+import graphene
 from graphene_django import DjangoObjectType
 
 from ..util.core_helpers import queryset_rules_filter
@@ -12,3 +13,14 @@ class RulesObjectType(DjangoObjectType):
         q = super().get_queryset(queryset, info)
 
         return queryset_rules_filter(info.context, q, perm)
+
+
+class FieldFileType(graphene.ObjectType):
+    url = graphene.String()
+    absolute_url = graphene.String()
+
+    def resolve_url(root, info, **kwargs):
+        return root.url if root else ""
+
+    def resolve_absolute_url(root, info, **kwargs):
+        return info.context.build_absolute_uri(root.url) if root else ""
diff --git a/aleksis/core/schema/pdf.py b/aleksis/core/schema/pdf.py
new file mode 100644
index 0000000000000000000000000000000000000000..1a3a3689b78849301710cd25e362313f71a7e308
--- /dev/null
+++ b/aleksis/core/schema/pdf.py
@@ -0,0 +1,14 @@
+
+import graphene
+from graphene_django import DjangoObjectType
+
+from ..models import PDFFile
+from .base import FieldFileType
+
+
+class PDFFileType(DjangoObjectType):
+    file = graphene.Field(FieldFileType)
+
+    class Meta:
+        model = PDFFile
+        exclude = ["html_file"]
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 6c5950cdcb1f9a147f5145559d4c9c183111d2c1..e64082f6876cf926ece10099cafbae639ffaf02f 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -10,16 +10,10 @@ from graphene_django.forms.mutation import DjangoModelFormMutation
 from ..forms import PersonForm
 from ..models import DummyPerson, Person
 from ..util.core_helpers import get_site_preferences, is_impersonate
+from .base import FieldFileType
 from .notification import NotificationType
 
 
-class FieldFileType(graphene.ObjectType):
-    url = graphene.String()
-
-    def resolve_url(root, info, **kwargs):
-        return root.url if root else ""
-
-
 class PersonPreferencesType(graphene.ObjectType):
     theme_design_mode = graphene.String()
 
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index c526d00c44ee11244cdfc143c4d7de3800dfd29a..a8cee0d037d7b06540a224a7a69c93f781fd74d2 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -374,9 +374,6 @@ urlpatterns = [
                     views.AssignPermissionView.as_view(),
                     name="assign_permission",
                 ),
-                path(
-                    "pdfs/<int:pk>", views.RedirectToPDFFile.as_view(), name="redirect_to_pdf_file"
-                ),
                 path("ical/", views.ICalFeedListView.as_view(), name="ical_feed_list"),
                 path("ical/create/", views.ICalFeedCreateView.as_view(), name="ical_feed_create"),
                 path(
diff --git a/aleksis/core/util/celery_progress.py b/aleksis/core/util/celery_progress.py
index d377d9d334c0243af210cc68e3474aeaea748b2e..008b979ec37207d05b2b22538a7761591ed8b22c 100644
--- a/aleksis/core/util/celery_progress.py
+++ b/aleksis/core/util/celery_progress.py
@@ -4,8 +4,7 @@ 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 redirect
+from django.http import HttpRequest, HttpResponseRedirect
 
 from celery.result import AsyncResult
 from celery_progress.backend import PROGRESS_STATE, AbstractProgressRecorder
@@ -220,4 +219,4 @@ def render_progress_page(
     assignment.additional_button_icon = button_icon or ""
     assignment.save()
 
-    return redirect("task_status", task_id=task_result.task_id)
+    return HttpResponseRedirect(request.build_absolute_uri(assignment.get_absolute_url()))
diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py
index 66b5e1a9fdf58d4630651e1795167df001f42567..01bf7601c92cc81425f04268a6276509ea4ab4f5 100644
--- a/aleksis/core/util/pdf.py
+++ b/aleksis/core/util/pdf.py
@@ -25,7 +25,7 @@ from selenium import webdriver
 from aleksis.core.celery import app
 from aleksis.core.models import PDFFile
 from aleksis.core.util.celery_progress import recorded_task, render_progress_page
-from aleksis.core.util.core_helpers import process_custom_context_processors
+from aleksis.core.util.core_helpers import has_person, process_custom_context_processors
 
 
 def _generate_pdf_with_chromium(temp_dir, pdf_path, html_url, lang):
@@ -96,6 +96,8 @@ def generate_pdf_from_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()
+    if request and has_person(request):
+        file_object.person = request.user.person
     file_object.html_file = html_file
     file_object.save()
 
@@ -141,7 +143,7 @@ def render_pdf(
 
     file_object, result = generate_pdf_from_template(template_name, context, request)
 
-    redirect_url = reverse("redirect_to_pdf_file", args=[file_object.pk])
+    redirect_url = f"/pdfs/{file_object.pk}"
 
     return render_progress_page(
         request,
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 8251a482707d5b07c787fb1c63d923cf348f86da..29d5ca002594e9ea9a989330c66d239e3eb856b3 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -99,7 +99,6 @@ from .models import (
     Group,
     GroupType,
     OAuthApplication,
-    PDFFile,
     Person,
     PersonalICalUrl,
     PersonInvitation,
@@ -1318,18 +1317,6 @@ class OAuth2RegisterView(PermissionRequiredMixin, AdvancedCreateView):
     form_class = OAuthApplicationForm
 
 
-class RedirectToPDFFile(SingleObjectMixin, View):
-    """Redirect to a generated PDF file."""
-
-    model = PDFFile
-
-    def get(self, *args, **kwargs):
-        file_object = self.get_object()
-        if not file_object.file:
-            raise Http404(_("The requested PDF file does not exist"))
-        return redirect(file_object.file.url)
-
-
 class CustomPasswordChangeView(LoginRequiredMixin, PermissionRequiredMixin, PasswordChangeView):
     """Custom password change view to allow to disable changing of password."""