lato 3.25.0 → 3.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -20
  3. data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
  4. data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
  5. data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
  6. data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
  7. data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
  8. data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
  9. data/app/assets/stylesheets/lato/application.scss +17 -0
  10. data/app/controllers/concerns/lato/componentable.rb +20 -8
  11. data/app/controllers/concerns/lato/sessionable.rb +39 -10
  12. data/app/controllers/lato/account_controller.rb +10 -8
  13. data/app/controllers/lato/application_controller.rb +25 -1
  14. data/app/controllers/lato/authentication_controller.rb +74 -39
  15. data/app/helpers/lato/components_helper.rb +52 -17
  16. data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
  17. data/app/models/lato/invitation.rb +1 -1
  18. data/app/models/lato/operation.rb +6 -2
  19. data/app/models/lato/user.rb +39 -9
  20. data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
  21. data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
  22. data/app/views/lato/account/_form-authenticator.html.erb +7 -3
  23. data/app/views/lato/account/_form-password.html.erb +3 -2
  24. data/app/views/lato/account/_form-web3.html.erb +5 -4
  25. data/app/views/lato/account/_form-webauthn.html.erb +3 -3
  26. data/app/views/lato/account/_security-confirmation.html.erb +8 -5
  27. data/app/views/lato/account/index.html.erb +1 -1
  28. data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
  29. data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
  30. data/app/views/lato/authentication/_form-signin.html.erb +2 -2
  31. data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
  32. data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
  33. data/app/views/lato/components/_form_item_input_signature.html.erb +1 -1
  34. data/app/views/lato/components/_index.html.erb +27 -22
  35. data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
  36. data/app/views/lato/components/_operation.html.erb +6 -3
  37. data/app/views/lato/components/_page_head.html.erb +1 -1
  38. data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
  39. data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
  40. data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
  41. data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
  42. data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
  43. data/app/views/layouts/lato/_action.html.erb +2 -2
  44. data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
  45. data/app/views/layouts/lato/_confirm.html.erb +6 -8
  46. data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
  47. data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
  48. data/app/views/layouts/lato/_network.html.erb +3 -3
  49. data/app/views/layouts/lato/application.html.erb +3 -2
  50. data/config/locales/en.yml +33 -0
  51. data/config/locales/fr.yml +30 -0
  52. data/config/locales/it.yml +30 -0
  53. data/config/locales/ro.yml +30 -0
  54. data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
  55. data/lib/lato/config.rb +4 -0
  56. data/lib/lato/engine.rb +5 -0
  57. data/lib/lato/version.rb +1 -1
  58. metadata +22 -15
@@ -30,7 +30,7 @@ user ||= Lato::User.new
30
30
  </div>
31
31
  <% elsif session[:web3_nonce] %>
32
32
  <div class="alert alert-light mb-0">
33
- <h4 class="alert-heading">Connecting..</h4>
33
+ <h3 class="alert-heading h4" role="status"><%= I18n.t('lato.connecting') %></h3>
34
34
  <div class="progress" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
35
35
  <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
36
36
  </div>
@@ -39,13 +39,14 @@ user ||= Lato::User.new
39
39
  <span id="account_form-web3__nonce" style="display: none;"><%= session[:web3_nonce] %></span>
40
40
  <%= form.hidden_field :web3_address, id: 'account_form-web3__input-web3_address' %>
41
41
  <%= form.hidden_field :web3_signed_nonce, id: 'account_form-web3__input-web3_signed_nonce' %>
42
- <%= lato_form_submit form, 'Confirm', class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
42
+ <%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
43
43
 
44
44
  <script>
45
45
  (async () => {
46
+ // Pinned exact version: dynamic import() can not carry an SRI hash (see README security checklist).
46
47
  const ethers = await import('https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.esm.min.js')
47
48
 
48
- const nonce = document.getElementById('account_form-web3__nonce').innerText
49
+ const nonce = document.getElementById('account_form-web3__nonce').textContent
49
50
  const inputAddress = document.getElementById('account_form-web3__input-web3_address')
50
51
  const inputSignedNonce = document.getElementById('account_form-web3__input-web3_signed_nonce')
51
52
  const submitButton = document.getElementById('account_form-web3__submit')
@@ -72,7 +73,7 @@ user ||= Lato::User.new
72
73
  </script>
73
74
  <% else %>
74
75
  <div class="alert alert-light mb-0">
75
- <h4 class="alert-heading"><%= I18n.t('lato.account_web3_start_title') %></h4>
76
+ <h3 class="alert-heading h4"><%= I18n.t('lato.account_web3_start_title') %></h3>
76
77
  <p>
77
78
  <%= raw I18n.t('lato.account_web3_start_description') %>
78
79
  </p>
@@ -20,7 +20,7 @@ end
20
20
 
21
21
  <% if user.webauthn_enabled? %>
22
22
  <div class="alert alert-success mb-3">
23
- <h4 class="alert-heading"><%= I18n.t('lato.account_webauthn_ready_title') %></h4>
23
+ <h3 class="alert-heading h4"><%= I18n.t('lato.account_webauthn_ready_title') %></h3>
24
24
  <p class="mb-0">
25
25
  <%= raw I18n.t('lato.account_webauthn_ready_description') %>
26
26
  </p>
@@ -36,7 +36,7 @@ end
36
36
  <%= form.hidden_field :webauthn_credential, data: { lato_account_webauthn_target: 'credentialInput' } %>
37
37
 
38
38
  <div class="alert alert-light mb-3" data-lato-account-webauthn-target="status">
39
- <h4 class="alert-heading"><%= I18n.t('lato.account_webauthn_in_progress_title') %></h4>
39
+ <h3 class="alert-heading h4"><%= I18n.t('lato.account_webauthn_in_progress_title') %></h3>
40
40
  <p class="mb-0">
41
41
  <%= raw I18n.t('lato.account_webauthn_in_progress_description') %>
42
42
  </p>
@@ -53,7 +53,7 @@ end
53
53
  </div>
54
54
  <% else %>
55
55
  <div class="alert alert-light mb-0">
56
- <h4 class="alert-heading"><%= I18n.t('lato.account_webauthn_start_title') %></h4>
56
+ <h3 class="alert-heading h4"><%= I18n.t('lato.account_webauthn_start_title') %></h3>
57
57
  <p>
58
58
  <%= raw I18n.t('lato.account_webauthn_start_description') %>
59
59
  </p>
@@ -1,12 +1,15 @@
1
+ <%# Rendered in several account forms on the same page: suffix ids so each label targets its own field. %>
2
+ <% password_id = form.field_id(:current_password, form.object_id) %>
3
+ <% code_id = form.field_id(:current_authenticator_code, form.object_id) %>
1
4
  <div class="mb-3" data-controller="lato-account-security" data-lato-account-security-challenge-url-value="<%= lato.account_security_challenge_path %>" data-lato-account-security-webauthn-value="<%= user.webauthn_enabled? && !user.authenticator_enabled? %>">
2
- <%= form.label :current_password, "Current password", class: "form-label" %>
3
- <%= form.password_field :current_password, class: "form-control", autocomplete: "current-password" %>
5
+ <%= form.label :current_password, I18n.t('lato.current_password'), class: "form-label", for: password_id %>
6
+ <%= form.password_field :current_password, id: password_id, class: "form-control", autocomplete: "current-password" %>
4
7
  <% if user.authenticator_enabled? %>
5
- <%= form.label :current_authenticator_code, "Current authenticator code", class: "form-label mt-2" %>
6
- <%= form.text_field :current_authenticator_code, value: nil, class: "form-control", autocomplete: "one-time-code", inputmode: "numeric" %>
8
+ <%= form.label :current_authenticator_code, I18n.t('lato.current_authenticator_code'), class: "form-label mt-2", for: code_id %>
9
+ <%= form.text_field :current_authenticator_code, id: code_id, value: nil, class: "form-control", autocomplete: "one-time-code", inputmode: "numeric" %>
7
10
  <% elsif user.webauthn_enabled? %>
8
11
  <%= form.hidden_field :current_webauthn_credential, value: nil, data: { lato_account_security_target: "credential" } %>
9
- <p class="text-muted mt-2">Confirm with your registered security key when submitting.</p>
12
+ <p class="text-muted mt-2"><%= I18n.t('lato.security_key_confirmation_help') %></p>
10
13
  <% end %>
11
14
  <p class="text-danger mt-2" data-lato-account-security-target="error" role="alert"></p>
12
15
  </div>
@@ -60,7 +60,7 @@
60
60
  </div>
61
61
  <div class="card-body">
62
62
  <div class="alert alert-warning">
63
- <h4 class="alert-heading"><%= I18n.t('lato.warning') %></h4>
63
+ <h3 class="alert-heading h4"><%= I18n.t('lato.warning') %></h3>
64
64
  <p>
65
65
  <%= raw I18n.t('lato.account_delete_details') %>
66
66
  </p>
@@ -7,31 +7,32 @@ email_readonly ||= false
7
7
  <div class="row">
8
8
  <div class="col col-12 col-md-6 mb-3">
9
9
  <%= lato_form_item_label form, :first_name %>
10
- <%= lato_form_item_input_text form, :first_name, required: true %>
10
+ <%= lato_form_item_input_text form, :first_name, required: true, autocomplete: 'given-name' %>
11
11
  </div>
12
12
 
13
13
  <div class="col col-12 col-md-6 mb-3">
14
14
  <%= lato_form_item_label form, :last_name %>
15
- <%= lato_form_item_input_text form, :last_name, required: true %>
15
+ <%= lato_form_item_input_text form, :last_name, required: true, autocomplete: 'family-name' %>
16
16
  </div>
17
17
 
18
18
  <div class="col col-12 mb-3">
19
19
  <%= lato_form_item_label form, :email %>
20
- <%= lato_form_item_input_email form, :email, required: true, readonly: email_readonly %>
20
+ <%= lato_form_item_input_email form, :email, required: true, readonly: email_readonly, autocomplete: 'email' %>
21
21
  </div>
22
22
 
23
23
  <div class="col col-12 col-md-6 mb-3">
24
24
  <%= lato_form_item_label form, :password %>
25
- <%= lato_form_item_input_password form, :password, required: true %>
25
+ <%= lato_form_item_input_password form, :password, required: true, autocomplete: 'new-password', minlength: Lato.config.auth_password_min_length, aria: { describedby: form.field_id(:password, :help) } %>
26
+ <div class="form-text" id="<%= form.field_id(:password, :help) %>"><%= I18n.t('lato.password_requirements', count: Lato.config.auth_password_min_length) %></div>
26
27
  </div>
27
28
 
28
29
  <div class="col col-12 col-md-6 mb-3">
29
30
  <%= lato_form_item_label form, :password_confirmation %>
30
- <%= lato_form_item_input_password form, :password_confirmation, required: true %>
31
+ <%= lato_form_item_input_password form, :password_confirmation, required: true, autocomplete: 'new-password' %>
31
32
  </div>
32
33
  </div>
33
34
 
34
35
  <div class="mb-3 text-muted" style="font-size: 14px;">
35
- <%= lato_form_item_input_check form, :accepted_privacy_policy_version, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')), required: true %>
36
- <%= lato_form_item_input_check form, :accepted_terms_and_conditions_version, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url, target: '_blank')), required: true %>
36
+ <%= lato_form_item_input_check form, :accepted_privacy_policy_version, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')).html_safe, required: true %>
37
+ <%= lato_form_item_input_check form, :accepted_terms_and_conditions_version, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url, target: '_blank')).html_safe, required: true %>
37
38
  </div>
@@ -10,11 +10,11 @@ user ||= Lato::User.new
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
11
 
12
12
  <div class="mb-3 text-center">
13
- <p><%= raw I18n.t('lato.authenticator_code_help', email: h(user.email_protected)) %></p>
13
+ <%= form.label :authenticator_code, raw(I18n.t('lato.authenticator_code_help', email: h(user.email_protected))), class: 'form-label' %>
14
14
  </div>
15
15
 
16
16
  <div class="mb-3">
17
- <%= lato_form_item_input_text form, :authenticator_code, required: true %>
17
+ <%= lato_form_item_input_text form, :authenticator_code, required: true, autocomplete: 'one-time-code', inputmode: 'numeric', pattern: '[0-9]{6}', maxlength: 6, autofocus: true %>
18
18
  </div>
19
19
 
20
20
  <div>
@@ -11,12 +11,12 @@ user ||= Lato::User.new
11
11
 
12
12
  <div class="mb-3">
13
13
  <%= lato_form_item_label form, :email %>
14
- <%= lato_form_item_input_email form, :email, required: true %>
14
+ <%= lato_form_item_input_email form, :email, required: true, autocomplete: 'username' %>
15
15
  </div>
16
16
 
17
17
  <div class="mb-3">
18
18
  <%= lato_form_item_label form, :password %>
19
- <%= lato_form_item_input_password form, :password, required: true %>
19
+ <%= lato_form_item_input_password form, :password, required: true, autocomplete: 'current-password' %>
20
20
  </div>
21
21
 
22
22
  <div>
@@ -10,13 +10,14 @@ code ||= nil
10
10
  <%= form_with model: user, url: lato.authentication_verify_email_action_path(id: user.id), class: 'text-center', data: { turbo_frame: '_self' } do |form| %>
11
11
  <% if code %>
12
12
  <%= form.hidden_field :code, value: code %>
13
- <button type="submit" style="display: none;"></button>
13
+ <%# Visible fallback when JavaScript is disabled or blocked by CSP. %>
14
+ <button type="submit" class="btn btn-primary"><%= I18n.t('lato.verify_email') %></button>
14
15
  <script>document.getElementById('authentication_form_verify_email').querySelector('button[type="submit"]').click()</script>
15
16
  <% else %>
16
17
  <%= lato_form_notices class: %w[mb-3], fixed: true %>
17
18
  <%= lato_form_errors user, class: %w[mb-3], fixed: true %>
18
19
 
19
- <%= link_to 'Torna all tuo account', lato.account_path, class: %w[btn btn-primary] %>
20
+ <%= link_to I18n.t('lato.back_to_account'), lato.account_path, class: %w[btn btn-primary] %>
20
21
  <% end %>
21
22
  <% end %>
22
23
  <% end %>
@@ -12,7 +12,7 @@ user ||= Lato::User.new
12
12
  <% if session[:web3_nonce] %>
13
13
 
14
14
  <div class="alert alert-light mb-0 text-center">
15
- <h4 class="alert-heading">Connecting..</h4>
15
+ <h4 class="alert-heading" role="status"><%= I18n.t('lato.connecting') %></h4>
16
16
  <div class="progress" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
17
17
  <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
18
18
  </div>
@@ -21,13 +21,14 @@ user ||= Lato::User.new
21
21
  <span id="account_form-web3__nonce" style="display: none;"><%= session[:web3_nonce] %></span>
22
22
  <%= form.hidden_field :web3_address, id: 'account_form-web3__input-web3_address' %>
23
23
  <%= form.hidden_field :web3_signed_nonce, id: 'account_form-web3__input-web3_signed_nonce' %>
24
- <%= lato_form_submit form, 'Confirm', class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
24
+ <%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
25
25
 
26
26
  <script>
27
27
  (async () => {
28
+ // Pinned exact version: dynamic import() can not carry an SRI hash (see README security checklist).
28
29
  const ethers = await import('https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.esm.min.js')
29
30
 
30
- const nonce = document.getElementById('account_form-web3__nonce').innerText
31
+ const nonce = document.getElementById('account_form-web3__nonce').textContent
31
32
  const inputAddress = document.getElementById('account_form-web3__input-web3_address')
32
33
  const inputSignedNonce = document.getElementById('account_form-web3__input-web3_signed_nonce')
33
34
  const submitButton = document.getElementById('account_form-web3__submit')
@@ -102,7 +102,7 @@ default_tab = existing_url ? 'current' : 'draw'
102
102
  <%# Clear button (solo nelle tab di editing) %>
103
103
  <div class="d-flex justify-content-end mt-2" data-lato-input-signature-target="clearRow">
104
104
  <button type="button"
105
- class="btn btn-outline-secondary btn-sm"
105
+ class="btn btn-outline-primary btn-sm"
106
106
  style="font-size: 0.75rem;"
107
107
  data-action="click->lato-input-signature#clearSignature">
108
108
  <i class="bi bi-eraser"></i> <%= I18n.t('lato.signature_clear') %>
@@ -9,9 +9,10 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
9
9
 
10
10
  <%= turbo_frame_tag "lato_index_#{key}_#{model_name_underscore}", class: "lato-index #{browser.device.mobile? ? 'lato-index-mob' : 'lato-index-desk'}" do %>
11
11
 
12
- <%= form_tag request.path, method: :get, data: { controller: 'lato-form lato-index', turbo_frame: '_self' } do %>
12
+ <%= form_tag request.path, method: :get, data: { controller: 'lato-form lato-index', lato_index_key_value: key, turbo_frame: '_self' } do %>
13
13
  <%= hidden_field_tag :key, key %>
14
- <% params.each do |k, v| %>
14
+ <% params.except(:controller, :action, :key).each do |k, v| %>
15
+ <% next unless v.is_a?(String) %>
15
16
  <%= hidden_field_tag k, v %>
16
17
  <% end %>
17
18
 
@@ -33,7 +34,7 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
33
34
  <% if custom_actions.any? %>
34
35
  <div class="btn-group ms-2">
35
36
  <% custom_actions.each do |action_key, action_params| %>
36
- <%= link_to action_params[:path], { class: 'btn btn-primary' }.merge(action_params) do %>
37
+ <%= link_to action_params[:path], { class: 'btn btn-primary', 'aria-label': action_params[:title] }.merge(action_params) do %>
37
38
  <i class="<%= action_params[:icon] %>"></i>
38
39
  <% end %>
39
40
  <% end %>
@@ -42,7 +43,7 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
42
43
 
43
44
  <% if dropdown_actions %>
44
45
  <div class="dropdown ms-2">
45
- <%= content_tag :button, { class: 'btn btn-primary dropdown-toggle', 'data-bs-toggle': 'dropdown' }.merge(dropdown_actions) do %>
46
+ <%= content_tag :button, { class: 'btn btn-primary dropdown-toggle', 'data-bs-toggle': 'dropdown', 'aria-label': dropdown_actions[:title] }.merge(dropdown_actions) do %>
46
47
  <i class="<%= dropdown_actions[:icon] %>"></i>
47
48
  <% end %>
48
49
  <ul class="dropdown-menu">
@@ -98,7 +99,7 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
98
99
  <span class="text-muted"><%= collection_total %> <%= I18n.t('lato.total_results').downcase %></span>
99
100
  <% end %>
100
101
  <% if collection.respond_to?(:total_pages) %>
101
- <div class="mt-3"><%= paginate(collection, param_name: "#{key}_page", total_pages: total_pages, window: 1) %></div>
102
+ <div class="mt-3"><%= lato_index_paginate(collection, key, total_pages: total_pages, window: 1) %></div>
102
103
  <% end %>
103
104
  </div>
104
105
 
@@ -109,28 +110,32 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
109
110
  <thead>
110
111
  <tr class="align-middle">
111
112
  <% columns.each do |column| %>
112
- <th scope="col" class="lato-index-desk-col-label lato-index-desk-col-label-<%= column %>">
113
+ <% column_label = collection_test_istance.respond_to?(column) ? collection.model.human_attribute_name(column) : lato_index_dynamic_label({
114
+ key: key,
115
+ model_name: model_name,
116
+ column: column
117
+ }) %>
118
+ <% sort_direction = sortable_columns.include?(column) ? { "#{column}|ASC" => 'ascending', "#{column}|DESC" => 'descending' }[params["#{key}_sort_by"]] : nil %>
119
+ <%= content_tag :th, scope: 'col', class: "lato-index-desk-col-label lato-index-desk-col-label-#{column}", 'aria-sort': sort_direction do %>
113
120
  <div class="d-flex align-items-center justify-content-between">
114
- <span><%= collection_test_istance.respond_to?(column) ? collection.model.human_attribute_name(column) : lato_index_dynamic_label({
115
- key: key,
116
- model_name: model_name,
117
- column: column
118
- }) %></span>
121
+ <span><%= column_label %></span>
119
122
  <% if sortable_columns.include?(column) %>
120
123
  <div class="btn-group ms-3 lato-index-col-sort">
121
- <div class="position-relative btn btn-sm <%= params["#{key}_sort_by"] == "#{column}|ASC" ? 'btn-primary' : 'btn-outline-primary' %>">
122
- <input type="submit" name="<%= "#{key}_sort_by" %>" value="<%= column %>|ASC" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0">
123
- <i class="bi bi-sort-up"></i>
124
- </div>
125
-
126
- <div class="position-relative btn btn-sm <%= params["#{key}_sort_by"] == "#{column}|DESC" ? 'btn-primary' : 'btn-outline-primary' %>">
127
- <input type="submit" name="<%= "#{key}_sort_by" %>" value="<%= column %>|DESC" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0">
128
- <i class="bi bi-sort-down"></i>
129
- </div>
124
+ <% [['ASC', 'ascending', 'bi-sort-up'], ['DESC', 'descending', 'bi-sort-down']].each do |direction, direction_name, icon| %>
125
+ <% active = params["#{key}_sort_by"] == "#{column}|#{direction}" %>
126
+ <button
127
+ type="submit"
128
+ name="<%= "#{key}_sort_by" %>"
129
+ value="<%= column %>|<%= direction %>"
130
+ class="btn btn-sm <%= active ? 'btn-primary' : 'btn-outline-primary' %>"
131
+ aria-label="<%= I18n.t("lato.sort_#{direction_name}", column: strip_tags(column_label.to_s)) %>"
132
+ aria-pressed="<%= active %>"
133
+ ><i class="bi <%= icon %>" aria-hidden="true"></i></button>
134
+ <% end %>
130
135
  </div>
131
136
  <% end %>
132
137
  </div>
133
- </th>
138
+ <% end %>
134
139
  <% end %>
135
140
  </tr>
136
141
  </thead>
@@ -156,7 +161,7 @@ total_pages = collection.empty? ? params["#{key}_page"]&.to_i : 999_999_999 if s
156
161
  <td colspan="<%= columns.length %>">
157
162
  <div class="d-flex justify-content-between align-items-center">
158
163
  <% if collection.respond_to?(:total_pages) %>
159
- <div><%= paginate(collection, param_name: "#{key}_page", total_pages: total_pages) %></div>
164
+ <div><%= lato_index_paginate(collection, key, total_pages: total_pages) %></div>
160
165
  <% else %>
161
166
  <div></div>
162
167
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <li class="nav-item">
2
- <%= link_to path, class: ['nav-link', ('active' if active)] do %>
2
+ <%= link_to path, class: ['nav-link', ('active' if active)], aria: { current: ('page' if active) } do %>
3
3
  <%= yield %>
4
4
  <% end %>
5
5
  </li>
@@ -29,7 +29,7 @@
29
29
 
30
30
  <% if operation.completed_status? %>
31
31
  <div class="text-center">
32
- <div class="fs-1 text-success">
32
+ <div class="fs-1 text-success-emphasis">
33
33
  <i class="bi bi-check-circle"></i>
34
34
  </div>
35
35
  <h2><%= I18n.t('lato.operation_completed_title') %></h2>
@@ -40,14 +40,17 @@
40
40
  <% end %>
41
41
 
42
42
  <% if operation.output_message? %>
43
- <p class="text-success fw-bold">
43
+ <p class="text-success-emphasis fw-bold">
44
44
  <%= operation.output_message %>
45
45
  </p>
46
46
  <% end %>
47
47
 
48
48
  <% if operation.output_action? %>
49
49
  <div class="mt-3 d-flex justify-content-center">
50
- <%= link_to operation.output_action['label'], operation.output_action['href'], { class: 'btn btn-primary' }.merge(operation.output_action['attributes']) %>
50
+ <%# Only http(s) and relative links: a javascript: href built from job input would run on click. %>
51
+ <% output_href = operation.output_action['href'].to_s %>
52
+ <% output_href = '#' unless output_href.start_with?('/', 'http://', 'https://') && !output_href.start_with?('//') %>
53
+ <%= link_to operation.output_action['label'], output_href, { class: 'btn btn-primary' }.merge(operation.output_action['attributes'] || {}) %>
51
54
  </div>
52
55
  <% end %>
53
56
  </div>
@@ -9,7 +9,7 @@
9
9
  </ol>
10
10
  <% end %>
11
11
 
12
- <h1><%= raw title %></h1>
12
+ <h1><%= title %></h1>
13
13
 
14
14
  <%= yield %>
15
15
  </div>
@@ -9,6 +9,7 @@ unless has_children
9
9
  class: ['nav-link position-relative', (active ? 'active' : 'link-dark')].join(' '),
10
10
  }
11
11
  link_to_options[:target] = '_blank' if external
12
+ link_to_options[:aria] = { current: 'page' } if active
12
13
  end
13
14
 
14
15
  %>
@@ -28,7 +29,7 @@ end
28
29
  <ul class="list-unstyled mt-2 border-start border-1 ps-2">
29
30
  <% children.each do |child| %>
30
31
  <li class="py-1">
31
- <%= link_to child[:path], class: ['nav-link py-1', (active == child[:key] ? 'active' : 'link-dark')].join(' ') do %>
32
+ <%= link_to child[:path], class: ['nav-link py-1', (active == child[:key] ? 'active' : 'link-dark')].join(' '), aria: { current: ('page' if active == child[:key]) } do %>
32
33
  <%= child[:label] %>
33
34
  <% end %>
34
35
  </li>
@@ -1,4 +1,4 @@
1
- <% if I18n.locale == 'en' %>
1
+ <% if I18n.locale.to_s != 'it' %>
2
2
 
3
3
  You have received an invitation to use <%= Lato.config.application_title %>.
4
4
  <br><br>
@@ -1,4 +1,4 @@
1
- <% if I18n.locale == 'en' %>
1
+ <% if I18n.locale.to_s != 'it' %>
2
2
 
3
3
  To have access to all the features of <%= Lato.config.application_title %> you must confirm your email address.
4
4
  <br><br>
@@ -1,4 +1,4 @@
1
- <% if I18n.locale == 'en' %>
1
+ <% if I18n.locale.to_s != 'it' %>
2
2
 
3
3
  An update to your <%= Lato.config.application_title %> account password has been requested.
4
4
  <br><br>
@@ -1,4 +1,4 @@
1
- <% if I18n.locale == 'en' %>
1
+ <% if I18n.locale.to_s != 'it' %>
2
2
 
3
3
  An access to your <b><%= Lato.config.application_title %></b> account has been made with the IP address <b><%= @ip_address %></b>.
4
4
  <br><br>
@@ -1,11 +1,11 @@
1
1
  <% 10.times do %>
2
2
 
3
- <div class="modal fade" data-lato-action-target="modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
3
+ <div class="modal fade" data-lato-action-target="modal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
4
4
  <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" data-lato-action-target="modalDialog">
5
5
  <div class="modal-content">
6
6
  <div class="modal-header">
7
7
  <h2 class="mb-0 mt-0 fs-4" data-lato-action-target="modalTitle"></h2>
8
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
8
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<%= I18n.t('lato.close') %>"></button>
9
9
  </div>
10
10
  <div class="modal-body" data-lato-action-target="modalBody">
11
11
  ...
@@ -2,7 +2,7 @@
2
2
  class="aside-opener shadow"
3
3
  data-controller="lato-aside-opener"
4
4
  data-action="click->lato-aside-opener#onClickToggle"
5
- aria-label="Toggle sidebar"
5
+ aria-label="<%= I18n.t('lato.toggle_sidebar') %>"
6
6
  >
7
- <i class="bi bi-layout-text-sidebar-reverse"></i>
7
+ <i class="bi bi-layout-text-sidebar-reverse" aria-hidden="true"></i>
8
8
  </button>
@@ -1,26 +1,24 @@
1
- <div class="lato-confirm modal fade" data-controller="lato-confirm" tabindex="-1" aria-hidden="true" data-bs-keyboard="false">
1
+ <div class="lato-confirm modal fade" data-controller="lato-confirm" tabindex="-1" aria-hidden="true" aria-labelledby="latoConfirmTitle">
2
2
  <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
3
3
  <div class="modal-content">
4
4
  <div class="modal-header">
5
- <h2 class="mb-0 mt-0 fs-4"><%= I18n.t('lato.confirm_title') %></h2>
6
- <button type="button" class="btn-close" data-action="click->lato-confirm#confirmCancel" aria-label="Close"></button>
5
+ <h2 class="mb-0 mt-0 fs-4" id="latoConfirmTitle"><%= I18n.t('lato.confirm_title') %></h2>
6
+ <button type="button" class="btn-close" data-action="click->lato-confirm#confirmCancel" aria-label="<%= I18n.t('lato.close') %>"></button>
7
7
  </div>
8
8
  <div class="modal-body" data-lato-confirm-target="modalBody">
9
9
  </div>
10
10
  <div class="modal-footer">
11
11
  <button
12
12
  type="button"
13
- class="btn btn-danger"
13
+ class="btn btn-secondary"
14
14
  data-action="click->lato-confirm#confirmCancel"
15
- aria-label="<%= I18n.t('lato.confirm_no') %>"
16
15
  ><%= I18n.t('lato.confirm_no') %></button>
17
16
  <button
18
17
  type="button"
19
- class="btn btn-success"
18
+ class="btn btn-primary"
20
19
  data-action="click->lato-confirm#confirmSuccess"
21
- aria-label="<%= I18n.t('lato.confirm_yes') %>"
22
20
  ><%= I18n.t('lato.confirm_yes') %></button>
23
21
  </div>
24
22
  </div>
25
23
  </div>
26
- </div>
24
+ </div>
@@ -1,11 +1,11 @@
1
1
  <div class="toast-container">
2
2
  <% if notice %>
3
3
  <div class="position-fixed end-0 bottom-0 p-3" style="z-index: 11;">
4
- <div data-controller="lato-feedback" id="feedbackNotice" class="toast hide" role="alert" aria-live="assertive" aria-atomic="true">
4
+ <div data-controller="lato-feedback" id="feedbackNotice" class="toast hide" role="status" aria-live="polite" aria-atomic="true">
5
5
  <div class="toast-header">
6
- <i class="bi bi-check-circle-fill text-success me-2"></i>
7
- <strong class="me-auto"><%= I18n.t('lato.operation_completed') %></strong>
8
- <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
6
+ <i class="bi bi-check-circle-fill text-success me-2" aria-hidden="true"></i>
7
+ <strong class="me-auto"><%= I18n.t('lato.notice_title') %></strong>
8
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="<%= I18n.t('lato.close') %>"></button>
9
9
  </div>
10
10
  <div class="toast-body">
11
11
  <%= notice %>
@@ -16,11 +16,12 @@
16
16
 
17
17
  <% if alert %>
18
18
  <div class="position-fixed end-0 bottom-0 p-3" style="z-index: 11;">
19
- <div data-controller="lato-feedback" id="feedbackAlert" class="toast hide" role="alert" aria-live="assertive" aria-atomic="true">
19
+ <%# Errors stay visible until dismissed: auto hiding gives no time to read them. %>
20
+ <div data-controller="lato-feedback" id="feedbackAlert" class="toast hide" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
20
21
  <div class="toast-header">
21
- <i class="bi bi-check-circle-fill text-danger me-2"></i>
22
- <strong class="me-auto"><%= I18n.t('lato.operation_failed') %></strong>
23
- <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
22
+ <i class="bi bi-exclamation-triangle-fill text-danger me-2" aria-hidden="true"></i>
23
+ <strong class="me-auto"><%= I18n.t('lato.alert_title') %></strong>
24
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="<%= I18n.t('lato.close') %>"></button>
24
25
  </div>
25
26
  <div class="toast-body">
26
27
  <%= alert %>
@@ -1,4 +1,4 @@
1
- <% if I18n.locale == 'en' %>
1
+ <% if I18n.locale.to_s != 'it' %>
2
2
  Hello,
3
3
  <br><br>
4
4
  <% else %>
@@ -1,4 +1,4 @@
1
- <div class="fixed-bottom p-3 d-none" data-controller="lato-network">
2
- <span class="badge rounded-pill text-bg-danger fs-6 d-none" data-lato-network-target="offline">You are offline</span>
3
- <span class="badge rounded-pill text-bg-success fs-6 d-none" data-lato-network-target="online">You are online</span>
1
+ <div class="fixed-bottom p-3 d-none" data-controller="lato-network" role="status" aria-live="polite">
2
+ <span class="badge rounded-pill text-bg-danger fs-6 d-none" data-lato-network-target="offline"><%= I18n.t('lato.network_offline') %></span>
3
+ <span class="badge rounded-pill text-bg-success fs-6 d-none" data-lato-network-target="online"><%= I18n.t('lato.network_online') %></span>
4
4
  </div>
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no,interactive-widget=resizes-content">
6
- <title><%= @layout_page_title %></title>
6
+ <title><%= @layout_page_title.presence || Lato.config.application_title %></title>
7
7
  <%= csrf_meta_tags %>
8
8
  <%= csp_meta_tag %>
9
9
 
@@ -27,6 +27,7 @@
27
27
  class="controller-<%= controller_name %> action-<%= action_name %> <%= @layout_body_classes.join(' ') %> <%= cookies[:lato_aside_hidden] == 'true' ? 'aside-hidden' : '' %>"
28
28
  data-controller="lato-action lato-guide"
29
29
  >
30
+ <a class="visually-hidden-focusable position-absolute top-0 start-0 p-2 bg-white" style="z-index: 2100;" href="#main"><%= I18n.t('lato.skip_to_content') %></a>
30
31
  <header>
31
32
  <%= render 'layouts/lato/navbar' %>
32
33
  </header>
@@ -39,7 +40,7 @@
39
40
  </aside>
40
41
  <% end %>
41
42
 
42
- <main class="<%= @layout_sidebar ? 'layout_sidebar' : '' %>">
43
+ <main id="main" tabindex="-1" class="<%= @layout_sidebar ? 'layout_sidebar' : '' %>">
43
44
  <%= render 'layouts/lato/content' %>
44
45
  </main>
45
46
  </div>