From 10eb0d1c2e270c7c390e72e5f732587d64c0627f Mon Sep 17 00:00:00 2001 From: catborise Date: Thu, 29 Oct 2026 17:20:05 +0300 Subject: [PATCH] feat: error pages that say what happened and what to do The 403, 404 and 500 pages said "Oops!" and one generic sentence; a bad request and a failed CSRF check fell back to Django's plain pages. Every error page now shares templates/error.html: a card with the status in a coloured badge with an icon, a heading in plain words, what happened and what to do, and Back and Home buttons. It uses only theme-defined Bootstrap classes, so it reads in the light and dark themes alike. - 400: the request was not accepted; a new address belongs in ALLOWED_HOSTS. - 403: the account lacks the permission; ask an administrator. - 403 CSRF: the form's security check failed; reload and submit again, and where to look when it happens on every form. - 404: the path, and that the object may be gone or hidden from the account. - 500: a libvirt failure on a page says the host's data could not be loaded, under libvirt's message; any other failure says it was logged and shows the time to give an administrator. --- templates/400.html | 13 +++++++ templates/403.html | 19 ++++----- templates/403_csrf.html | 14 +++++++ templates/404.html | 20 +++++----- templates/500.html | 25 ++++++------ templates/error.html | 39 +++++++++++++++++++ webvirtcloud/tests/test_connection_errors.py | 2 + webvirtcloud/tests/test_error_pages.py | 41 ++++++++++++++++++++ 8 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 templates/400.html create mode 100644 templates/403_csrf.html create mode 100644 templates/error.html create mode 100644 webvirtcloud/tests/test_error_pages.py diff --git a/templates/400.html b/templates/400.html new file mode 100644 index 00000000..827dfb31 --- /dev/null +++ b/templates/400.html @@ -0,0 +1,13 @@ +{% extends "error.html" %} +{% load i18n %} + +{% block title %}{% trans "Bad request" %}{% endblock %} +{% block code %}400{% endblock %} +{% block status_classes %}text-bg-warning{% endblock %} +{% block icon %}bi-exclamation-triangle{% endblock %} +{% block heading %}{% trans "Bad request" %}{% endblock %} + +{% block text %} +

{% trans "The panel could not accept this request." %}

+

{% trans "If the panel was opened at a new address, an administrator must add that address to ALLOWED_HOSTS." %}

+{% endblock %} diff --git a/templates/403.html b/templates/403.html index 6b0a7e8a..86519c6a 100644 --- a/templates/403.html +++ b/templates/403.html @@ -1,15 +1,12 @@ -{% extends "base.html" %} +{% extends "error.html" %} {% load i18n %} -{% block title %}{% trans "403" %}{% endblock %} -{% block content %} -
-
-

{% trans 'Oops!'%}

-

{% trans "403 Forbidden" %}

+{% block title %}{% trans "Access denied" %}{% endblock %} +{% block code %}403{% endblock %} +{% block status_classes %}text-bg-warning{% endblock %} +{% block icon %}bi-shield-lock{% endblock %} +{% block heading %}{% trans "Access denied" %}{% endblock %} -

{% trans "You do not have permission to access this page." %}

- ← {% trans 'Back'%} -
-
+{% block text %} +

{% trans "Your account does not have the permission for this page or action. Ask an administrator for it." %}

{% endblock %} diff --git a/templates/403_csrf.html b/templates/403_csrf.html new file mode 100644 index 00000000..77e52c6a --- /dev/null +++ b/templates/403_csrf.html @@ -0,0 +1,14 @@ +{% extends "error.html" %} +{% load i18n %} + +{% block title %}{% trans "Form could not be submitted" %}{% endblock %} +{% block code %}403{% endblock %} +{% block status_classes %}text-bg-warning{% endblock %} +{% block icon %}bi-shield-exclamation{% endblock %} +{% block heading %}{% trans "Form could not be submitted" %}{% endblock %} + +{% block text %} +

{% trans "This submission was rejected because its security check failed. It usually happens when the page was open for a long time, or after signing in or out in another tab." %}

+

{% trans "Go back, reload the page and submit the form again." %}

+

{% trans "If it happens on every form, the browser may block cookies, or the panel's proxy or address settings may be wrong. The reason is written to the panel's log." %}

+{% endblock %} diff --git a/templates/404.html b/templates/404.html index dd42e10f..7f4dd90a 100644 --- a/templates/404.html +++ b/templates/404.html @@ -1,15 +1,13 @@ -{% extends "base.html" %} +{% extends "error.html" %} {% load i18n %} -{% block title %}{% trans "404" %}{% endblock %} -{% block content %} -
-
-

{% trans 'Oops!'%}

-

{% trans "404 Not Found" %}

+{% block title %}{% trans "Page not found" %}{% endblock %} +{% block code %}404{% endblock %} +{% block status_classes %}text-bg-secondary{% endblock %} +{% block icon %}bi-file-earmark-x{% endblock %} +{% block heading %}{% trans "Page not found" %}{% endblock %} -

{% trans "The requested page was not found on this server." %}

- ← {% trans 'Back'%} -
-
+{% block text %} +

{% blocktrans with path=request_path %}Nothing was found at {{ path }}, or your account cannot see it.{% endblocktrans %}

+

{% trans "The virtual machine or host may have been deleted, or the link is wrong." %}

{% endblock %} diff --git a/templates/500.html b/templates/500.html index e8f1e569..451d86ab 100644 --- a/templates/500.html +++ b/templates/500.html @@ -1,15 +1,18 @@ -{% extends "base.html" %} +{% extends "error.html" %} {% load i18n %} -{% block title %}{% trans "500" %}{% endblock %} -{% block content %} -
-
-

{% trans 'Oops!' %}

-

{% trans "500 Internal Server Error" %}

+{% block title %}{% if libvirt_error %}{% trans "Could not load data from the host" %}{% else %}{% trans "Server error" %}{% endif %}{% endblock %} +{% block code %}500{% endblock %} +{% block status_classes %}text-bg-danger{% endblock %} +{% block icon %}{% if libvirt_error %}bi-hdd-network{% else %}bi-exclamation-octagon{% endif %}{% endblock %} +{% block heading %}{% if libvirt_error %}{% trans "Could not load data from the host" %}{% else %}{% trans "Server error" %}{% endif %}{% endblock %} -

{% trans "The server encountered an internal error or misconfiguration and was unable to complete you request." %}

- ← {% trans 'Back'%} -
-
+{% block text %} + {% if libvirt_error %} +

{% trans "This page needs data from the host, and libvirt returned the error shown above." %}

+

{% trans "Check that the host is running and reachable, then reload the page." %}

+ {% else %} +

{% trans "The panel failed while handling this request. This is a fault in the panel, not something you did, and it was written to the panel's log." %}

+

{% now "Y-m-d H:i:s" as now %}{% blocktrans %}Reload the page to try again. If it keeps happening, tell an administrator the time: {{ now }}.{% endblocktrans %}

+ {% endif %} {% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 00000000..d360274e --- /dev/null +++ b/templates/error.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
+
+
+
+
+ + + + {% trans "HTTP status" %} + {% block code %}{% endblock %} + + +
+ +

{% block heading %}{% endblock %}

+ +
+ {% block text %}{% endblock %} +
+ + +
+
+
+
+{% endblock %} diff --git a/webvirtcloud/tests/test_connection_errors.py b/webvirtcloud/tests/test_connection_errors.py index 84ca5e3d..4525b8f6 100644 --- a/webvirtcloud/tests/test_connection_errors.py +++ b/webvirtcloud/tests/test_connection_errors.py @@ -66,6 +66,8 @@ class ConnectionErrorPageTests(TestCase): response, messages = self.handle(self.admin, libvirt_error(libvirt.VIR_FROM_RPC, HOST_DETAIL), path="/instances/1/", method="get") self.assertEqual(response.status_code, 500) + self.assertIn(b"Could not load data from the host", response.content) + self.assertIn(HOST_DETAIL.encode(), response.content) self.assertTrue(messages) def test_administrators_see_connection_details(self): diff --git a/webvirtcloud/tests/test_error_pages.py b/webvirtcloud/tests/test_error_pages.py new file mode 100644 index 00000000..50692eb1 --- /dev/null +++ b/webvirtcloud/tests/test_error_pages.py @@ -0,0 +1,41 @@ +"""The error pages say what happened and what to do, in the panel's layout.""" + +from django.contrib.auth import get_user_model +from django.http import Http404 +from django.test import Client, RequestFactory, TestCase +from django.views.defaults import page_not_found, server_error + + +class ErrorPageTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user("ep-user", password="pw") + self.client.force_login(self.user) + + def test_a_bad_request(self): + response = self.client.get("/", HTTP_HOST="unknown.example") + self.assertContains(response, "Bad request", status_code=400) + self.assertContains(response, "ALLOWED_HOSTS", status_code=400) + + def test_a_page_the_user_may_not_open(self): + response = self.client.get("/computes/") + self.assertContains(response, "Access denied", status_code=403) + self.assertContains(response, "Ask an administrator", status_code=403) + + def test_a_form_that_fails_its_security_check(self): + response = Client(enforce_csrf_checks=True).post("/accounts/login/", {"username": "ep-user", "password": "pw"}) + self.assertContains(response, "Form could not be submitted", status_code=403) + self.assertContains(response, "reload the page and submit the form again", status_code=403) + + def test_a_page_that_is_not_there_names_its_path(self): + response = self.client.get("/no/such/page/") + self.assertContains(response, "Page not found", status_code=404) + self.assertContains(response, "/no/such/page/", status_code=404) + + def test_the_reason_of_a_not_found_is_not_shown(self): + response = page_not_found(RequestFactory().get("/datasource/"), Http404("libvirt detail of the host")) + self.assertNotIn(b"libvirt detail", response.content) + + def test_a_server_error_renders_without_a_request_context(self): + response = server_error(RequestFactory().get("/")) + self.assertContains(response, "Server error", status_code=500) + self.assertContains(response, "tell an administrator the time", status_code=500)