mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
refactor(templates): the shared page scripts load from static files
A step towards a Content-Security-Policy without inline scripts. The scripts every page or many pages share now load from static files: the error focus of base.html, the please-wait dialog, the sign-in forms' password toggle and busy button, the jobs pages' reload while a job runs, and the start of the job panel and of the VM list's state poll, whose values come as json_script data. Cancel links go back through a data-history-back attribute instead of a javascript: URL. The IE8 html5shiv/respond.js block, X-UA-Compatible and shrink-to-fit are gone: no supported browser reads them.
This commit is contained in:
parent
ab15fafe9c
commit
393aecb8f1
23 changed files with 112 additions and 137 deletions
|
|
@ -21,7 +21,7 @@
|
|||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'x' %}
|
||||
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'x' %}
|
||||
{% trans "Cancel" %}</a>
|
||||
<button type="submit" form="password-change" class="btn btn-success">
|
||||
{% bs_icon 'check2' %} {% trans "Change" %}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="mb-0 float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
|
||||
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
|
||||
<button type="submit" form="create-update" class="btn btn-success">
|
||||
{% bs_icon 'envelope-open' %} {% trans "Send" %}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
|
||||
|
|
@ -55,7 +56,7 @@
|
|||
<div class="form-text">{% trans "Leave empty if you have not set up two-factor authentication." %}</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit">
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Verifying..." %}">
|
||||
<i class="bi bi-shield-lock-fill me-1"></i>
|
||||
<span id="btn-text">{% trans "Verify & Sign In" %}</span>
|
||||
</button>
|
||||
|
|
@ -72,31 +73,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle password visibility
|
||||
$('#btn-toggle-pwd').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var $pwd = $('#id_password');
|
||||
var $icon = $('#toggle-icon');
|
||||
if ($pwd.attr('type') === 'password') {
|
||||
$pwd.attr('type', 'text');
|
||||
$icon.removeClass('bi-eye').addClass('bi-eye-slash');
|
||||
} else {
|
||||
$pwd.attr('type', 'password');
|
||||
$icon.removeClass('bi-eye-slash').addClass('bi-eye');
|
||||
}
|
||||
});
|
||||
|
||||
// Submit loading state
|
||||
$('#form-signin').on('submit', function() {
|
||||
var $btn = $('#btn-signin');
|
||||
$btn.prop('disabled', true);
|
||||
$btn.html(
|
||||
'<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>' +
|
||||
'{% trans "Verifying..." %}'
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/login_form.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock title %}
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit">
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Signing in..." %}">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>
|
||||
<span id="btn-text">{% trans "Sign In" %}</span>
|
||||
</button>
|
||||
|
|
@ -61,31 +62,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle password visibility
|
||||
$('#btn-toggle-pwd').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var $pwd = $('#id_password');
|
||||
var $icon = $('#toggle-icon');
|
||||
if ($pwd.attr('type') === 'password') {
|
||||
$pwd.attr('type', 'text');
|
||||
$icon.removeClass('bi-eye').addClass('bi-eye-slash');
|
||||
} else {
|
||||
$pwd.attr('type', 'password');
|
||||
$icon.removeClass('bi-eye-slash').addClass('bi-eye');
|
||||
}
|
||||
});
|
||||
|
||||
// Submit loading state
|
||||
$('#form-signin').on('submit', function() {
|
||||
var $btn = $('#btn-signin');
|
||||
$btn.prop('disabled', true);
|
||||
$btn.html(
|
||||
'<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>' +
|
||||
'{% trans "Signing in..." %}'
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/login_form.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
{# the VM list reloads when its VMs change: vm_states.js #}
|
||||
{{ states_url|json_script:"vm-states-url" }}
|
||||
<script src="{% static 'js/vm_states.js' %}"></script>
|
||||
<script>followVmStates(JSON.parse(document.getElementById("vm-states-url").textContent));</script>
|
||||
<script src="{% static 'js/vm_states_start.js' %}"></script>
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class PageControlsTestCase(PageControls, TestCase):
|
|||
viewer = self.user("ctl_js_viewer", perms=("view_instances",))
|
||||
for user in (superuser, viewer):
|
||||
self.controls(user)
|
||||
scripts = lxml_html.fromstring(self.last_page).xpath("//script[not(@src)]/text()")
|
||||
scripts = lxml_html.fromstring(self.last_page).xpath("//script[not(@src) and not(@type='application/json')]/text()")
|
||||
for number, script in enumerate(scripts):
|
||||
with self.subTest(user=user.username, script=number):
|
||||
result = subprocess.run(
|
||||
|
|
|
|||
|
|
@ -134,8 +134,6 @@ class TemplateHtmlConcatenationTestCase(TestCase):
|
|||
# .val() / DOM APIs for server data instead).
|
||||
HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
|
||||
ALLOWED_HTML_SINKS = {
|
||||
("accounts/templates/login.html", "$btn.html("): "static spinner markup",
|
||||
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
|
||||
("instances/templates/instance.html", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
|
||||
("instances/templates/instance.html", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
|
||||
}
|
||||
|
|
|
|||
6
operations/static/operations/follow_active.js
Normal file
6
operations/static/operations/follow_active.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Reloads the page while it shows an active job, so the page follows the job to its end.
|
||||
setInterval(function () {
|
||||
if (!document.hidden && !document.querySelector("form :focus")) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 5000);
|
||||
9
operations/static/operations/job_panel_start.js
Normal file
9
operations/static/operations/job_panel_start.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Starts the job panel of a resource page from its settings (job_panel_script tag).
|
||||
(function () {
|
||||
var settings = JSON.parse(document.getElementById("job-panel-settings").textContent);
|
||||
window.resourceJobPanel = jobPanel(document.getElementById("job-panel"), {
|
||||
resource: settings.resource,
|
||||
job: function (id) { return settings.jobs + id + "/"; },
|
||||
cancel: function (id) { return settings.jobs + id + "/cancel/"; },
|
||||
}, settings.csrf_token, settings.remembered);
|
||||
})();
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
{% load static %}
|
||||
{# Reloads the page while it shows an active job, so the page follows the job to its end. #}
|
||||
<script id="follow-active-jobs">
|
||||
setInterval(function () {
|
||||
if (!document.hidden && !document.querySelector("form :focus")) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
<script id="follow-active-jobs" src="{% static 'operations/follow_active.js' %}"></script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
{% load static %}
|
||||
{{ settings|json_script:"job-panel-settings" }}
|
||||
<script src="{% static 'operations/job_panel.js' %}"></script>
|
||||
<script>
|
||||
window.resourceJobPanel = jobPanel(document.getElementById("job-panel"), {
|
||||
resource: "{% url 'operation-resource' %}?key={{ resource|urlencode:'' }}",
|
||||
job: function (id) { return "{% url 'operation-list' %}" + id + "/"; },
|
||||
cancel: function (id) { return "{% url 'operation-list' %}" + id + "/cancel/"; },
|
||||
}, "{{ csrf_token }}", {{ remembered|safe }});
|
||||
</script>
|
||||
<script src="{% static 'operations/job_panel_start.js' %}"></script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import json
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
|
||||
from django import template
|
||||
from django.middleware.csrf import get_token
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timesince import timesince
|
||||
|
|
@ -71,5 +73,9 @@ def wrap_at_slash(label):
|
|||
@register.inclusion_tag("operations/job_panel_script.html", takes_context=True)
|
||||
def job_panel_script(context, resource):
|
||||
"""The job panel's script for the page of resource."""
|
||||
return {"resource": resource, "csrf_token": context.get("csrf_token"),
|
||||
"remembered": json.dumps(take_remembered(context["request"], resource))}
|
||||
return {"settings": {
|
||||
"resource": reverse("operation-resource") + "?key=" + quote(resource, safe=""),
|
||||
"jobs": reverse("operation-list"),
|
||||
"csrf_token": get_token(context["request"]),
|
||||
"remembered": take_remembered(context["request"], resource),
|
||||
}}
|
||||
|
|
|
|||
7
static/js/back_links.js
Normal file
7
static/js/back_links.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// A link marked data-history-back goes back to the previous page.
|
||||
document.addEventListener("click", function (event) {
|
||||
if (event.target.closest("[data-history-back]")) {
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
20
static/js/error_focus.js
Normal file
20
static/js/error_focus.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// After a refused submission, start at the error. The places an error
|
||||
// can be, most specific first: a refused field, its form's summary, an
|
||||
// open dialog's copy of the messages (the page's are behind it), the
|
||||
// page's messages. A page that sets itself up first has
|
||||
// data-error-focus="manual" and calls this when it is ready.
|
||||
const ERROR_PLACES = [".is-invalid", ".form-errors", ".modal.show .modal-messages .alert-danger",
|
||||
"#messages .alert-danger"];
|
||||
|
||||
function focusFirstError() {
|
||||
for (const selector of ERROR_PLACES) {
|
||||
const target = [...document.querySelectorAll(selector)].find(e => e.offsetParent !== null);
|
||||
if (target) {
|
||||
if (!target.matches("input, select, textarea")) target.tabIndex = -1;
|
||||
target.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.focusFirstError = focusFirstError;
|
||||
if (document.body.dataset.errorFocus !== "manual") focusFirstError();
|
||||
19
static/js/login_form.js
Normal file
19
static/js/login_form.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// The sign-in forms (login, OTP login): the eye button shows or hides the
|
||||
// password; while the form is sent, its button is disabled and says so.
|
||||
document.getElementById("btn-toggle-pwd").addEventListener("click", function () {
|
||||
var password = document.getElementById("id_password");
|
||||
var shown = password.type === "password";
|
||||
password.type = shown ? "text" : "password";
|
||||
var icon = document.getElementById("toggle-icon");
|
||||
icon.classList.toggle("bi-eye", !shown);
|
||||
icon.classList.toggle("bi-eye-slash", shown);
|
||||
});
|
||||
|
||||
document.getElementById("form-signin").addEventListener("submit", function () {
|
||||
var button = document.getElementById("btn-signin");
|
||||
button.disabled = true;
|
||||
var spinner = document.createElement("span");
|
||||
spinner.className = "spinner-border spinner-border-sm me-2";
|
||||
spinner.setAttribute("aria-hidden", "true");
|
||||
button.replaceChildren(spinner, button.dataset.busyText);
|
||||
});
|
||||
15
static/js/please_wait.js
Normal file
15
static/js/please_wait.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// The please-wait dialog (pleasewaitdialog.html) of a page that submits a long request.
|
||||
function showPleaseWaitDialog() {
|
||||
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
|
||||
keyboard: false
|
||||
});
|
||||
pleaseModal.show();
|
||||
backgroundJobRunning = true;
|
||||
}
|
||||
function hidePleaseWaitDialog() {
|
||||
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
|
||||
keyboard: false
|
||||
})
|
||||
pleaseModal.hide();
|
||||
backgroundJobRunning = false;
|
||||
}
|
||||
2
static/js/vm_states_start.js
Normal file
2
static/js/vm_states_start.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Starts following the VM list's states (vm_states_poll.html).
|
||||
followVmStates(JSON.parse(document.getElementById("vm-states-url").textContent));
|
||||
|
|
@ -5,8 +5,7 @@
|
|||
{% get_current_language as LANGUAGE_CODE %}<html lang="{{ LANGUAGE_CODE }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="WebVirtCloud panel for manage virtual machine">
|
||||
<meta name="author" content="anatoliy.guskov@gmail.com">
|
||||
|
||||
|
|
@ -25,12 +24,6 @@
|
|||
<!-- Custom Fonts -->
|
||||
<link href="{% static "css/bootstrap-icons.min.css" %}" rel="stylesheet" type="text/css">
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
||||
</head>
|
||||
|
||||
|
|
@ -90,28 +83,8 @@
|
|||
<script src="{% static 'js/tablesort.min.js' %}"></script>
|
||||
<script src="{% static 'js/tablesort.number.min.js' %}"></script>
|
||||
<script src="{% static 'js/sort_tables.js' %}"></script>
|
||||
<script>
|
||||
// After a refused submission, start at the error. The places an error
|
||||
// can be, most specific first: a refused field, its form's summary, an
|
||||
// open dialog's copy of the messages (the page's are behind it), the
|
||||
// page's messages. A page that sets itself up first has
|
||||
// data-error-focus="manual" and calls this when it is ready.
|
||||
const ERROR_PLACES = [".is-invalid", ".form-errors", ".modal.show .modal-messages .alert-danger",
|
||||
"#messages .alert-danger"];
|
||||
|
||||
function focusFirstError() {
|
||||
for (const selector of ERROR_PLACES) {
|
||||
const target = [...document.querySelectorAll(selector)].find(e => e.offsetParent !== null);
|
||||
if (target) {
|
||||
if (!target.matches("input, select, textarea")) target.tabIndex = -1;
|
||||
target.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.focusFirstError = focusFirstError;
|
||||
if (document.body.dataset.errorFocus !== "manual") focusFirstError();
|
||||
</script>
|
||||
<script src="{% static 'js/error_focus.js' %}"></script>
|
||||
<script src="{% static 'js/back_links.js' %}"></script>
|
||||
{% block script %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<a class="btn btn-outline-secondary" href="javascript:history.back()">{% trans "Cancel" %}</a>
|
||||
<a class="btn btn-outline-secondary" href="#" data-history-back>{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-danger">{% trans "Delete" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<div class="card-body">{% bootstrap_form form %}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<a class="btn btn-outline-secondary" href="javascript:history.back()">{% trans "Cancel" %}</a>
|
||||
<a class="btn btn-outline-secondary" href="#" data-history-back>{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
</div>
|
||||
|
||||
<div class="d-grid d-sm-flex gap-2 mt-4">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">
|
||||
<a class="btn btn-primary" href="#" data-history-back>
|
||||
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% load i18n static %}
|
||||
<!-- Please wait dialog -->
|
||||
<div class="modal fade" id="pleaseWaitDialog" tabindex="-1" aria-labelledby="pleaseWaitDialogLabel" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-sm">
|
||||
|
|
@ -15,19 +15,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function showPleaseWaitDialog() {
|
||||
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
|
||||
keyboard: false
|
||||
});
|
||||
pleaseModal.show();
|
||||
backgroundJobRunning = true;
|
||||
}
|
||||
function hidePleaseWaitDialog() {
|
||||
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
|
||||
keyboard: false
|
||||
})
|
||||
pleaseModal.hide();
|
||||
backgroundJobRunning = false;
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'js/please_wait.js' %}"></script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""After a refused submission the page starts at the error: base.html's
|
||||
script focuses the first visible invalid field, else the error message.
|
||||
"""After a refused submission the page starts at the error: error_focus.js
|
||||
focuses the first visible invalid field, else the error message.
|
||||
The script runs in Node against a stand-in document."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
|
@ -11,11 +10,7 @@ from pathlib import Path
|
|||
|
||||
from django.conf import settings
|
||||
|
||||
SCRIPT = re.search(
|
||||
r"<script>\s*(// After a refused submission.*?)</script>",
|
||||
Path(settings.BASE_DIR, "templates/base.html").read_text(),
|
||||
re.S,
|
||||
).group(1)
|
||||
SCRIPT = Path(settings.BASE_DIR, "static/js/error_focus.js").read_text()
|
||||
|
||||
HARNESS = """
|
||||
const els = %s;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue