Merge branch '96-notify-user-about-incorrect-email-pw-combination' into 'main'

Resolve "Notify user about incorrect email/pw combination"

Closes #94 and #96

See merge request stackspin/dashboard!62
This commit is contained in:
Arie Peterson 2022-10-18 12:43:39 +00:00
commit 696ffba9fe
5 changed files with 303 additions and 323 deletions

View file

@ -1,5 +1,12 @@
# Changelog # Changelog
## Unreleased
- Fix login welcome message
- Clarify "set new password" button (#94)
- Show error messages when login fails, for example when a wrong password was
entered (#96)
## [0.5.1] ## [0.5.1]
- Fix bug of missing "Monitoring" app access when creating a new user. - Fix bug of missing "Monitoring" app access when creating a new user.

View file

@ -118,7 +118,13 @@ def login():
identity = get_auth() identity = get_auth()
if identity: if identity:
return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, id=id) if 'name' in identity['traits']:
# Add a space in front of the "name" so the template can put it
# between "Welcome" and the comma
name = " " + identity['traits']['name']
else:
name = ""
return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, name=name)
flow = request.args.get("flow") flow = request.args.get("flow")

View file

@ -16,257 +16,244 @@
*/ */
// Check if an auth flow is configured and redirect to auth page in that // Check if an auth flow is configured and redirect to auth page in that
// case. // case.
function check_flow_auth() { function check_flow_auth() {
var state = Cookies.get('flow_state'); var state = Cookies.get('flow_state');
var url = Cookies.get('auth_url'); var url = Cookies.get('auth_url');
if (state == 'auth') { if (state == 'auth') {
Cookies.set('flow_state',''); Cookies.set('flow_state', '');
window.location.href = url; window.location.href = url;
} }
} }
// Check if there if the flow is expired, if so, reset the cookie // Check if there if the flow is expired, if so, reset the cookie
function check_flow_expired() { function check_flow_expired() {
var state = Cookies.get('flow_state'); var state = Cookies.get('flow_state');
if (state == 'flow_expired') { if (state == 'flow_expired') {
Cookies.set('flow_state',''); Cookies.set('flow_state', '');
$("#contentFlowExpired").show(); $('#contentFlowExpired').show();
} }
} }
// The script executed on login flows // The script executed on login flows
function flow_login() { function flow_login() {
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/login/flows?id=' + flow;
var flow = $.urlParam('flow'); // Query the Kratos backend to know what fields to render for the
var uri = api_url + 'self-service/login/flows?id=' + flow; // current flow
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
// Render login form (group: password)
var form_html = render_form(data, 'password');
$('#contentLogin').html(form_html);
// Query the Kratos backend to know what fields to render for the var messages_html = render_messages(data);
// current flow $('#contentMessages').html(messages_html);
$.ajax({ },
type: "GET", complete: function (obj) {
url: uri, // If we get a 410, the flow is expired, need to refresh the flow
success: function(data) { if (obj.status == 410) {
Cookies.set('flow_state', 'flow_expired');
// Render login form (group: password) // If we call the page without arguments, we get a new flow
var html = render_form(data, 'password'); window.location.href = 'login';
$("#contentLogin").html(html); }
},
}, });
complete: function(obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state','flow_expired');
// If we call the page without arguments, we get a new flow
window.location.href = 'login';
}
}
});
} }
// This is called after a POST on settings. It tells if the save was // This is called after a POST on settings. It tells if the save was
// successful and display / handles based on that outcome // successful and display / handles based on that outcome
function flow_settings_validate() { function flow_settings_validate() {
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/settings/flows?id=' + flow;
var flow = $.urlParam('flow'); $.ajax({
var uri = api_url + 'self-service/settings/flows?id=' + flow; type: 'GET',
url: uri,
success: function (data) {
// We had success. We save that fact in our flow_state
// cookie and regenerate a new flow
if (data.state == 'success') {
Cookies.set('flow_state', 'settings_saved');
$.ajax( { // Redirect to generate new flow ID
type: "GET", window.location.href = 'settings';
url: uri, } else {
success: function(data) { // There was an error, Kratos does not specify what is
// wrong. So we just show the general error message and
// let the user figure it out. We can re-use the flow-id
$('#contentProfileSaveFailed').show();
// We had success. We save that fact in our flow_state // For now, this code assumes that only the password can fail
// cookie and regenerate a new flow // validation. Other forms might need to be added in the future.
if (data.state == 'success') { html = render_form(data, 'password');
Cookies.set('flow_state', 'settings_saved'); $('#contentPassword').html(html);
}
// Redirect to generate new flow ID },
window.location.href = 'settings'; });
}
else {
// There was an error, Kratos does not specify what is
// wrong. So we just show the general error message and
// let the user figure it out. We can re-use the flow-id
$("#contentProfileSaveFailed").show();
// For now, this code assumes that only the password can fail
// validation. Other forms might need to be added in the future.
html = render_form(data, 'password')
$("#contentPassword").html(html)
}
}
});
} }
// Render the settings flow, this is where users can change their personal // Render the settings flow, this is where users can change their personal
// settings, like name and password. The form contents are defined by Kratos // settings, like name and password. The form contents are defined by Kratos
function flow_settings() { function flow_settings() {
// Get the details from the current flow from kratos
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/settings/flows?id=' + flow;
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
var state = Cookies.get('flow_state');
// Get the details from the current flow from kratos // If we have confirmation the settings are saved, show the
var flow = $.urlParam('flow'); // notification
var uri = api_url + 'self-service/settings/flows?id=' + flow; if (state == 'settings_saved') {
$.ajax({ $('#contentProfileSaved').show();
type: "GET", Cookies.set('flow_state', 'settings');
url: uri, }
success: function(data) {
var state = Cookies.get('flow_state') // Hide prfile section if we are in recovery state
// so the user is not confused by other fields. The user
// probably want to setup a password only first.
if (state == 'recovery') {
$('#contentProfile').hide();
}
// If we have confirmation the settings are saved, show the // Render the password & profile form based on the fields we got
// notification // from the API
if (state == 'settings_saved') { var html = render_form(data, 'password');
$("#contentProfileSaved").show(); $('#contentPassword').html(html);
Cookies.set('flow_state', 'settings');
}
// Hide prfile section if we are in recovery state html = render_form(data, 'profile');
// so the user is not confused by other fields. The user $('#contentProfile').html(html);
// probably want to setup a password only first.
if (state == 'recovery') {
$("#contentProfile").hide();
}
// If the submit button is hit, execute the POST with Ajax.
$('#formpassword').submit(function (e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
// Render the password & profile form based on the fields we got var form = $(this);
// from the API var url = form.attr('action');
var html = render_form(data, 'password');
$("#contentPassword").html(html);
html = render_form(data, 'profile');
$("#contentProfile").html(html);
// If the submit button is hit, execute the POST with Ajax.
$("#formpassword").submit(function(e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
var form = $(this);
var url = form.attr('action');
$.ajax({
type: "POST",
url: url,
data: form.serialize(),
complete: function(obj) {
// Validate the settings
flow_settings_validate();
},
});
});
},
complete: function(obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state','flow_expired');
window.location.href = 'settings';
}
}
});
$.ajax({
type: 'POST',
url: url,
data: form.serialize(),
complete: function (obj) {
// Validate the settings
flow_settings_validate();
},
});
});
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state', 'flow_expired');
window.location.href = 'settings';
}
},
});
} }
function flow_recover() { function flow_recover() {
var flow = $.urlParam('flow'); var flow = $.urlParam('flow');
var uri = api_url + 'self-service/recovery/flows?id=' + flow; var uri = api_url + 'self-service/recovery/flows?id=' + flow;
$.ajax( { $.ajax({
type: "GET", type: 'GET',
url: uri, url: uri,
success: function(data) { success: function (data) {
// Render the recover form, method 'link'
var html = render_form(data, 'link');
$('#contentRecover').html(html);
// Render the recover form, method 'link' // Do form post as an AJAX call
var html = render_form(data, 'link'); $('#formlink').submit(function (e) {
$("#contentRecover").html(html); // avoid to execute the actual submit of the form.
e.preventDefault();
// Do form post as an AJAX call var form = $(this);
$("#formlink").submit(function(e) { var url = form.attr('action');
// avoid to execute the actual submit of the form. // keep stat we are in recovery
e.preventDefault(); Cookies.set('flow_state', 'recovery');
$.ajax({
var form = $(this); type: 'POST',
var url = form.attr('action'); url: url,
data: form.serialize(), // serializes the form's elements.
// keep stat we are in recovery success: function (data) {
Cookies.set('flow_state', 'recovery'); // Show the request is sent out
$.ajax({ $('#contentRecover').hide();
type: "POST", $('#contentRecoverRequested').show();
url: url, },
data: form.serialize(), // serializes the form's elements. });
success: function(data) });
{ },
complete: function (obj) {
// Show the request is sent out // If we get a 410, the flow is expired, need to refresh the flow
$("#contentRecover").hide(); if (obj.status == 410) {
$("#contentRecoverRequested").show(); Cookies.set('flow_state', 'flow_expired');
} window.location.href = 'recovery';
}); }
}); },
});
},
complete: function(obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state','flow_expired');
window.location.href = 'recovery';
}
}
});
} }
// Based on Kratos UI data and a group name, get the full form for that group. // Based on Kratos UI data and a group name, get the full form for that group.
// kratos groups elements which belongs together in a group and should be posted // kratos groups elements which belongs together in a group and should be posted
// at once. The elements in the default group should be part of all other // at once. The elements in the default group should be part of all other
// groups. // groups.
// //
// data: data object as returned form the API // data: data object as returned form the API
// group: group to render. // group: group to render.
function render_form(data, group) { function render_form(data, group) {
// Create form
var action = data.ui.action;
var method = data.ui.method;
var form = "<form id='form" + group + "' method='" + method + "' action='" + action + "'>";
// Create form for (const node of data.ui.nodes) {
var action = data.ui.action; var name = node.attributes.name;
var method = data.ui.method; var type = node.attributes.type;
var form = "<form id='form"+group+"' method='"+method+"' action='"+action+"'>"; var value = node.attributes.value;
var messages = node.messages;
for (const node of data.ui.nodes) { if (node.group == 'default' || node.group == group) {
var elm = getFormElement(type, name, value, messages);
var name = node.attributes.name; form += elm;
var type = node.attributes.type;
var value = node.attributes.value;
var messages = node.messages
if (node.group == 'default' || node.group == group) {
var elm = getFormElement(type, name, value, messages);
form += elm;
}
} }
form += "</form>"; }
return form; form += '</form>';
return form;
}
// Check if there are any general messages to show to the user and render them
function render_messages(data) {
var messages = data.ui.messages;
if (messages == []) {
return '';
}
var html = '<ul>';
messages.forEach((message) => {
html += '<li>';
html += message.text;
html += '</li>';
});
html += '</ul>';
return html;
} }
// Return form element based on name, including help text (sub), placeholder etc. // Return form element based on name, including help text (sub), placeholder etc.
// Kratos give us form names and types and specifies what to render. However // Kratos give us form names and types and specifies what to render. However
// it does not provide labels or translations. This function returns a HTML // it does not provide labels or translations. This function returns a HTML
// form element based on the fields provided by Kratos with proper names and // form element based on the fields provided by Kratos with proper names and
// labels // labels
// type: input type, usual "input", "hidden" or "submit". But bootstrap types // type: input type, usual "input", "hidden" or "submit". But bootstrap types
// like "email" are also supported // like "email" are also supported
@ -274,98 +261,80 @@ function render_form(data, group) {
// value: If there is already a value known, show it // value: If there is already a value known, show it
// messages: error messages related to the field // messages: error messages related to the field
function getFormElement(type, name, value, messages) { function getFormElement(type, name, value, messages) {
console.log("Getting form element", type, name, value, messages) console.log('Getting form element', type, name, value, messages);
if (value == undefined) { if (value == undefined) {
value = ''; value = '';
} }
if (typeof(messages) == "undefined") { if (typeof messages == 'undefined') {
messages = [] messages = [];
} }
if (name == 'email' || name == 'traits.email') { if (name == 'email' || name == 'traits.email') {
return getFormInput( return getFormInput(
'email', 'email',
name, name,
value, value,
'E-mail address', 'E-mail address',
'Please enter your e-mail address here', 'Please enter your e-mail address here',
'Please provide your e-mail address. We will send a recovery ' + 'Please provide your e-mail address. We will send a recovery link to that e-mail address.',
'link to that e-mail address.', messages,
messages, );
); }
}
if (name == 'traits.username') { if (name == 'traits.username') {
return getFormInput( return getFormInput('name', name, value, 'Username', 'Please provide an username', null, messages);
'name', }
name,
value,
'Username',
'Please provide an username',
null,
messages,
);
}
if (name == 'traits.name') { if (name == 'traits.name') {
return getFormInput( return getFormInput('name', name, value, 'Full name', 'Please provide your full name', null, messages);
'name', }
name,
value,
'Full name',
'Please provide your full name',
null,
messages,
);
}
if (name == 'identifier') {
return getFormInput(
'email',
name,
value,
'E-mail address',
'Please provide your e-mail address to log in',
null,
messages,
);
}
if (name == 'identifier') { if (name == 'password') {
return getFormInput( return getFormInput('password', name, value, 'Password', 'Please provide your password', null, messages);
'email', }
name,
value,
'E-mail address',
'Please provide your e-mail address to log in',
null,
messages,
);
}
if (name == 'password') { if (type == 'hidden' || name == 'traits.uuid') {
return getFormInput( return (
'password', `
name, <input type="hidden" class="form-control" id="` +
value, name +
'Password', `"
'Please provide your password', name="` +
null, name +
messages, `" value='` +
); value +
} `'>`
);
}
if (type == 'submit') {
if (type == 'hidden' || name == 'traits.uuid') { return (
`<div class="form-group">
return ` <input type="hidden" name="` +
<input type="hidden" class="form-control" id="`+name+`" name +
name="`+name+`" value='`+value+`'>`; `" value="` +
} value +
`">
if (type == 'submit') {
return `<div class="form-group">
<input type="hidden" name="`+name+`" value="`+value+`">
<button type="submit" class="btn btn-primary">Go!</button> <button type="submit" class="btn btn-primary">Go!</button>
</div>`; </div>`
} );
}
return getFormInput('input', name, value, name, null,null, messages);
return getFormInput('input', name, value, name, null, null, messages);
} }
// Usually called by getFormElement, generic function to generate an // Usually called by getFormElement, generic function to generate an
@ -378,56 +347,58 @@ function getFormElement(type, name, value, messages) {
// param help: Additional help text, displayed below the field in small font // param help: Additional help text, displayed below the field in small font
// param messages: Message about failed input // param messages: Message about failed input
function getFormInput(type, name, value, label, placeHolder, help, messages) { function getFormInput(type, name, value, label, placeHolder, help, messages) {
if (typeof(help) == "undefined" || help == null) { if (typeof help == 'undefined' || help == null) {
help = "" help = '';
} }
console.log("Messages: ", messages); console.log('Messages: ', messages);
// Id field for help element // Id field for help element
var nameHelp = name + "Help"; var nameHelp = name + 'Help';
var element = '<div class="form-group">'; var element = '<div class="form-group">';
element += '<label for="'+name+'">'+label+'</label>'; element += '<label for="' + name + '">' + label + '</label>';
element += '<input type="'+type+'" class="form-control" id="'+name+'" name="'+name+'" '; element += '<input type="' + type + '" class="form-control" id="' + name + '" name="' + name + '" ';
// messages get appended to help info // messages get appended to help info
if (messages.length) { if (messages.length) {
for (message in messages) { for (message in messages) {
console.log("adding message", messages[message]) console.log('adding message', messages[message]);
help += messages[message]['text'] help += messages[message]['text'];
}
} }
}
// If we are a password field, add a eye icon to reveal password // If we are a password field, add a eye icon to reveal password
if (value) { if (value) {
element += 'value="'+value+'" '; element += 'value="' + value + '" ';
} }
if (help) { if (help) {
element += 'aria-describedby="' + nameHelp +'" '; element += 'aria-describedby="' + nameHelp + '" ';
} }
if (placeHolder) { if (placeHolder) {
element += 'placeholder="'+placeHolder+'" '; element += 'placeholder="' + placeHolder + '" ';
} }
element += ">"; element += '>';
if (help) { if (help) {
element += element +=
`<small id="`+nameHelp+`" class="form-text text-muted">` + help + ` `<small id="` +
nameHelp +
`" class="form-text text-muted">` +
help +
`
</small>`; </small>`;
} }
element += '</div>'; element += '</div>';
return element; return element;
} }
// $.urlParam get parameters from the URI. Example: id = $.urlParam('id'); // $.urlParam get parameters from the URI. Example: id = $.urlParam('id');
$.urlParam = function(name) { $.urlParam = function (name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href); var results = new RegExp('[?&]' + name + '=([^&#]*)').exec(window.location.href);
if (results==null) { if (results == null) {
return null; return null;
} }
return decodeURI(results[1]) || 0; return decodeURI(results[1]) || 0;
}; };

View file

@ -1,5 +1,3 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
@ -17,11 +15,9 @@
<div id="contentMessages"></div> <div id="contentMessages"></div>
<div id="contentWelcome">Welcome {{ id['name'] }},<br/><br/> <div id="contentWelcome">
You are already logged in. Welcome{{ name }}, you are logged in.
</div> </div>

View file

@ -19,7 +19,7 @@
<div id="contentMessages"></div> <div id="contentMessages"></div>
<div id="contentLogin"></div> <div id="contentLogin"></div>
<div id="contentHelp"> <div id="contentHelp">
<a href='recovery'>Forget password?</a> | <a href='https://stackspin.net'>About stackspin</a> <a href='recovery'>Set new password</a> | <a href='https://stackspin.net'>About stackspin</a>
</div> </div>
{% endblock %} {% endblock %}