aboutsummaryrefslogtreecommitdiff
path: root/resources/assets/javascripts/bootstrap/data_secure.js
blob: 1b3b7a1072e84276758766321a2d668e04891d1c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import { $gettext } from '../lib/gettext';

/**
 * Secure forms or form elements by displaying a warning on page unload if
 * there are unsaved changes.
 *
 * Add the data-attribute "secure" to any <form> or :input element and when
 * the page is reloaded or the surrounding dialog is closed, a confirmation
 * dialog will appear.
 *
 * There are two config options that may be passed via the data-secure
 * attribute.
 *
 * {
 *     always: Secures the element regardless of it's changed state. If a
 *             form should always be secured, use this. If you want to exclude
 *             an element from the security check, set always on that element
 *             to false (but you should use the shorthand `data-secure="false"`
 *             since the wording "always" is a little bit misleading in this
 *             case).
 *     exists: Dynamically added nodes cannot be detected and thus will
 *             never be taken into account when detecting whether the
 *             element's value has changed. Specify a css selector that
 *             precisely identifies elements that are only present when the
 *             element needs to be secured.
 *
 * These options may be passed as a json encoded array like this:
 *
 *     <form data-secure='{always: false, exists: "#foo > .bar"}'>
 *
 * But since you will probably never need the two options at once, you may
 * either pass just a boolean value to the data-secure attribute for setting
 * the "always" option or any other non-object value as the "exists" option:
 *
 *     <form data-secure="true">
 *
 *  is equivalent to
 *
 *     <form data-secure='{always: true}'>
 *
 * and
 *
 *     <form data-secure="#foo .bar">
 *
 *  is equivalent to
 *
 *     <form data-secure='{exists: "#foo .bar"}'>
 *
 * @author  Jan-Hendrik Willms <tleilax+studip@gmail.com>
 * @license GPL2 or any later version
 * @since   Stud.IP 3.4
 */

/**
 * Normalize arbitrary input to config option object
 *
 * @param mixed input Arbitrary input
 * @return Object config
 */
function normalizeConfig(input) {
    var config = {
        always: null,
        exists: false
    };
    if ($.isPlainObject(input)) {
        config = $.extend(config, input);
    } else if (input === false || input === true) {
        config.always = input;
    } else {
        config.exists = input || false;
    }
    return config;
}

/**
 * Detect any changes on elements with the data-secure attribute
 * in a given context.
 *
 * @param mixed context Optional context in which the elements should be
 *                      located
 * @return bool indicating whether any changes have occured
 */
function detectChanges(context) {
    var changed = false;

    $('[data-secure]', context || document).each(function() {
        if (
            $(this)
                .closest('form')
                .data().secureSkip
        ) {
            return;
        }

        var data = $(this).data().secure;
        var config = normalizeConfig(data);
        var items = $(this).is('form') ? $(this).find(':input:not([data-secure])') : $(this);

        if (config.always === true) {
            changed = true;
        } else if (config.always !== false && config.exists === false) {
            items
                .filter('[name]')
                .filter(':not(:checkbox,:radio)')
                .each(function() {
                    changed = changed || (this.defaultValue !== undefined && this.value !== this.defaultValue);
                });
            items
                .filter('[name]')
                .filter(':checkbox,:radio')
                .each(function() {
                    changed = changed || (this.defaultChecked !== undefined && this.checked !== this.defaultChecked);
                });
        }

        if (!changed && config.exists !== false) {
            changed = $(config.exists, this).length > 0;
        }
    });

    return changed;
}

// Secure browser window on refresh via the beforeunload event
$(window).on('beforeunload', function(event) {
    if (detectChanges() === false) {
        return;
    }

    event = event || window.event || {};
    event.returnValue = $gettext('Ihre Eingaben wurden bislang noch nicht gespeichert.');
    return event.returnValue;
});

// Secure dialogs on close via the dialogbeforeclose event
$(document).on('dialogbeforeclose', function(event) {
    if (detectChanges(event.target) === false) {
        return true;
    }

    if (!window.confirm($gettext('Ihre Eingaben wurden bislang noch nicht gespeichert.'))) {
        event.preventDefault();
        event.stopPropagation();
        return false;
    }

    return true;
});

// Mark form on submit so it will be skipped during security check
$(document)
    .on('submit', 'form[data-secure],form:has([data-secure])', function() {
        $(this)
            .closest('form')
            .data('secure-skip', true);
    })
    .on('change', 'form[data-secure],form *[data-secure]', function() {
        $(this)
            .closest('form')
            .data('secure-skip', false);
    });