diff options
| -rw-r--r-- | app/controllers/elevations_controller.rb | 11 | ||||
| -rw-r--r-- | app/controllers/otp_challenges_controller.rb | 7 | ||||
| -rw-r--r-- | app/views/layouts/_elevation_banner.html.erb | 47 | ||||
| -rw-r--r-- | app/views/layouts/admin.html.erb | 1 | ||||
| -rw-r--r-- | config/locales/de.yml | 10 | ||||
| -rw-r--r-- | config/locales/en.yml | 10 | ||||
| -rw-r--r-- | config/routes.rb | 4 | ||||
| -rw-r--r-- | lib/authenticated_system.rb | 20 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 40 |
9 files changed, 147 insertions, 3 deletions
diff --git a/app/controllers/elevations_controller.rb b/app/controllers/elevations_controller.rb index 804b8f95..229f33d8 100644 --- a/app/controllers/elevations_controller.rb +++ b/app/controllers/elevations_controller.rb | |||
| @@ -41,4 +41,15 @@ class ElevationsController < ApplicationController | |||
| 41 | flash[:notice] = t("flash.elevation.dropped") | 41 | flash[:notice] = t("flash.elevation.dropped") |
| 42 | redirect_to admin_path | 42 | redirect_to admin_path |
| 43 | end | 43 | end |
| 44 | |||
| 45 | def renew | ||
| 46 | if renew_elevation! | ||
| 47 | flash[:notice] = t("flash.elevation.renewed", | ||
| 48 | :minutes => AuthenticatedSystem::ELEVATION_MAX_AGE.in_minutes.to_i, | ||
| 49 | :left => elevation_extensions_left) | ||
| 50 | else | ||
| 51 | flash[:error] = t("flash.elevation.cannot_renew") | ||
| 52 | end | ||
| 53 | redirect_back(:fallback_location => admin_path, :allow_other_host => false) | ||
| 54 | end | ||
| 44 | end | 55 | end |
diff --git a/app/controllers/otp_challenges_controller.rb b/app/controllers/otp_challenges_controller.rb index 87586241..2526d1fb 100644 --- a/app/controllers/otp_challenges_controller.rb +++ b/app/controllers/otp_challenges_controller.rb | |||
| @@ -31,7 +31,12 @@ class OtpChallengesController < ApplicationController | |||
| 31 | # an admin who logs in and goes straight to user management | 31 | # an admin who logs in and goes straight to user management |
| 32 | # is already elevated | 32 | # is already elevated |
| 33 | elevate! if user.is_admin? | 33 | elevate! if user.is_admin? |
| 34 | flash[:notice] = t("flash.common.logged_in") | 34 | flash[:notice] = if user.is_admin? |
| 35 | t("flash.elevation.granted_at_login", | ||
| 36 | :minutes => AuthenticatedSystem::ELEVATION_MAX_AGE.in_minutes.to_i) | ||
| 37 | else | ||
| 38 | t("flash.common.logged_in") | ||
| 39 | end | ||
| 35 | redirect_to safe_return_to(return_to, :default => admin_path) | 40 | redirect_to safe_return_to(return_to, :default => admin_path) |
| 36 | else | 41 | else |
| 37 | flash.now[:error] = t("flash.otp.code_mismatch") | 42 | flash.now[:error] = t("flash.otp.code_mismatch") |
diff --git a/app/views/layouts/_elevation_banner.html.erb b/app/views/layouts/_elevation_banner.html.erb new file mode 100644 index 00000000..a7d15a2e --- /dev/null +++ b/app/views/layouts/_elevation_banner.html.erb | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | <% if elevated? %> | ||
| 2 | <div id="elevation_banner" | ||
| 3 | data-expires-at="<%= elevation_expires_at.to_i %>" | ||
| 4 | data-label-minutes="<%= t(".minutes_short") %>" | ||
| 5 | data-label-less-than-a-minute="<%= t(".less_than_a_minute") %>" | ||
| 6 | data-label-expired="<%= t(".expired") %>"> | ||
| 7 | <span class="elevation_state"> | ||
| 8 | <%= icon("shield-check", library: "tabler", "aria-hidden": true) %> | ||
| 9 | <%= t(".active") %> | ||
| 10 | <span id="elevation_remaining"></span> | ||
| 11 | </span> | ||
| 12 | <span class="elevation_actions"> | ||
| 13 | <% if elevation_extensions_left > 0 %> | ||
| 14 | <%= button_to t(".renew"), renew_elevation_path, method: :post, | ||
| 15 | form: { class: "button_to state_changing" } %> | ||
| 16 | <% end %> | ||
| 17 | <%= button_to t(".drop"), elevation_path, method: :delete, | ||
| 18 | form: { class: "button_to destructive" } %> | ||
| 19 | </span> | ||
| 20 | </div> | ||
| 21 | |||
| 22 | <%= javascript_tag nonce: true do %> | ||
| 23 | (function () { | ||
| 24 | var banner = document.getElementById("elevation_banner"); | ||
| 25 | if (!banner) return; | ||
| 26 | var out = document.getElementById("elevation_remaining"); | ||
| 27 | var expires = parseInt(banner.dataset.expiresAt, 10) * 1000; | ||
| 28 | |||
| 29 | function tick() { | ||
| 30 | var ms = expires - Date.now(); | ||
| 31 | if (ms <= 0) { | ||
| 32 | out.textContent = banner.dataset.labelExpired; | ||
| 33 | banner.classList.remove("elevation_soon"); | ||
| 34 | banner.classList.add("elevation_expired"); | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | var mins = Math.floor(ms / 60000); | ||
| 38 | out.textContent = mins < 1 | ||
| 39 | ? banner.dataset.labelLessThanAMinute | ||
| 40 | : banner.dataset.labelMinutes.replace("%{count}", mins); | ||
| 41 | if (mins < 5) banner.classList.add("elevation_soon"); | ||
| 42 | setTimeout(tick, 15000); | ||
| 43 | } | ||
| 44 | tick(); | ||
| 45 | })(); | ||
| 46 | <% end %> | ||
| 47 | <% end %> | ||
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 43f09b96..a84c8927 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb | |||
| @@ -43,6 +43,7 @@ | |||
| 43 | </div> | 43 | </div> |
| 44 | </div> | 44 | </div> |
| 45 | <div class="admin_content_spacer"></div> | 45 | <div class="admin_content_spacer"></div> |
| 46 | <%= render "layouts/elevation_banner" %> | ||
| 46 | <%= render "layouts/flash" %> | 47 | <%= render "layouts/flash" %> |
| 47 | <div id="content"> | 48 | <div id="content"> |
| 48 | <%= yield %> | 49 | <%= yield %> |
diff --git a/config/locales/de.yml b/config/locales/de.yml index 7edffe2d..6b5c97cc 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -630,6 +630,9 @@ de: | |||
| 630 | removed: "%{lang}-Übersetzung aus dem Entwurf entfernt. Veröffentlichen, um das dauerhaft zu machen." | 630 | removed: "%{lang}-Übersetzung aus dem Entwurf entfernt. Veröffentlichen, um das dauerhaft zu machen." |
| 631 | elevation: | 631 | elevation: |
| 632 | dropped: "Administrative Rechte abgelegt." | 632 | dropped: "Administrative Rechte abgelegt." |
| 633 | renewed: "Administrative Rechte um %{minutes} Minuten verlängert. Noch %{left} Verlängerungen möglich." | ||
| 634 | cannot_renew: "Verlängern nicht möglich. Rechte müssen mit einem aktuellen Code neu angefordert werden." | ||
| 635 | granted_at_login: "Angemeldet. Administrative Rechte gelten %{minutes} Minuten." | ||
| 633 | 636 | ||
| 634 | assets: | 637 | assets: |
| 635 | index: | 638 | index: |
| @@ -767,3 +770,10 @@ de: | |||
| 767 | log_out: "Abmelden" | 770 | log_out: "Abmelden" |
| 768 | social_meta: | 771 | social_meta: |
| 769 | site_description: "Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." | 772 | site_description: "Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." |
| 773 | elevation_banner: | ||
| 774 | active: "Administrative Rechte aktiv, " | ||
| 775 | minutes_short: "noch %{count} Min." | ||
| 776 | less_than_a_minute: "weniger als 1 Min." | ||
| 777 | expired: "abgelaufen" | ||
| 778 | renew: "Verlängern" | ||
| 779 | drop: "Ablegen" | ||
diff --git a/config/locales/en.yml b/config/locales/en.yml index 9a8041bf..e4c7ecc4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -589,6 +589,9 @@ en: | |||
| 589 | removed: "%{lang} translation removed from the draft. Publish to make this permanent." | 589 | removed: "%{lang} translation removed from the draft. Publish to make this permanent." |
| 590 | elevation: | 590 | elevation: |
| 591 | dropped: "Administrative rights dropped." | 591 | dropped: "Administrative rights dropped." |
| 592 | renewed: "Administrative rights extended by %{minutes} minutes. %{left} extensions left." | ||
| 593 | cannot_renew: "Cannot extend. Request the rights again with a current code." | ||
| 594 | granted_at_login: "Signed in. Administrative rights last %{minutes} minutes." | ||
| 592 | 595 | ||
| 593 | assets: | 596 | assets: |
| 594 | index: | 597 | index: |
| @@ -710,3 +713,10 @@ en: | |||
| 710 | log_out: "Log out" | 713 | log_out: "Log out" |
| 711 | social_meta: | 714 | social_meta: |
| 712 | site_description: "The Chaos Computer Club is a galactic community of life forms campaigning for freedom of information and the assessment of the impact of technology." | 715 | site_description: "The Chaos Computer Club is a galactic community of life forms campaigning for freedom of information and the assessment of the impact of technology." |
| 716 | elevation_banner: | ||
| 717 | active: "Administrative rights active —" | ||
| 718 | minutes_short: "%{count} min left" | ||
| 719 | less_than_a_minute: "less than 1 min" | ||
| 720 | expired: "expired" | ||
| 721 | renew: "Extend" | ||
| 722 | drop: "Drop" | ||
diff --git a/config/routes.rb b/config/routes.rb index 4c37e70f..58e1e632 100644 --- a/config/routes.rb +++ b/config/routes.rb | |||
| @@ -106,7 +106,9 @@ Cccms::Application.routes.draw do | |||
| 106 | end | 106 | end |
| 107 | resource :otp_enrollment, :only => [:show, :create, :update, :destroy] | 107 | resource :otp_enrollment, :only => [:show, :create, :update, :destroy] |
| 108 | resource :otp_challenge, :only => [:new, :create] | 108 | resource :otp_challenge, :only => [:new, :create] |
| 109 | resource :elevation, :only => [:new, :create, :destroy] | 109 | resource :elevation, :only => [:new, :create, :destroy] do |
| 110 | post :renew | ||
| 111 | end | ||
| 110 | 112 | ||
| 111 | resources :menu_items, :except => :show do | 113 | resources :menu_items, :except => :show do |
| 112 | member do | 114 | member do |
diff --git a/lib/authenticated_system.rb b/lib/authenticated_system.rb index 9a351dde..04d8051f 100644 --- a/lib/authenticated_system.rb +++ b/lib/authenticated_system.rb | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | module AuthenticatedSystem | 1 | module AuthenticatedSystem |
| 2 | SESSION_MAX_AGE = 7.days | 2 | SESSION_MAX_AGE = 7.days |
| 3 | ELEVATION_MAX_AGE = 30.minutes | 3 | ELEVATION_MAX_AGE = 30.minutes |
| 4 | MAX_ELEVATION_EXTENSIONS = 3 | ||
| 4 | 5 | ||
| 5 | protected | 6 | protected |
| 6 | # Returns true or false if the user is logged in. | 7 | # Returns true or false if the user is logged in. |
| @@ -35,10 +36,26 @@ module AuthenticatedSystem | |||
| 35 | 36 | ||
| 36 | def elevate! | 37 | def elevate! |
| 37 | session[:elevated_at] = Time.now.to_i | 38 | session[:elevated_at] = Time.now.to_i |
| 39 | session[:elevation_extensions] = 0 | ||
| 40 | end | ||
| 41 | |||
| 42 | def elevation_extensions_left | ||
| 43 | return 0 unless elevated? | ||
| 44 | MAX_ELEVATION_EXTENSIONS - session[:elevation_extensions].to_i | ||
| 45 | end | ||
| 46 | |||
| 47 | def renew_elevation! | ||
| 48 | return false unless elevated? | ||
| 49 | return false unless elevation_extensions_left > 0 | ||
| 50 | |||
| 51 | session[:elevation_extensions] = session[:elevation_extensions].to_i + 1 | ||
| 52 | session[:elevated_at] = Time.now.to_i | ||
| 53 | true | ||
| 38 | end | 54 | end |
| 39 | 55 | ||
| 40 | def drop_elevation! | 56 | def drop_elevation! |
| 41 | session.delete(:elevated_at) | 57 | session.delete(:elevated_at) |
| 58 | session.delete(:elevation_extensions) | ||
| 42 | end | 59 | end |
| 43 | 60 | ||
| 44 | # Check if the user is authorized | 61 | # Check if the user is authorized |
| @@ -113,7 +130,7 @@ module AuthenticatedSystem | |||
| 113 | # available as ActionView helper methods. | 130 | # available as ActionView helper methods. |
| 114 | def self.included(base) | 131 | def self.included(base) |
| 115 | base.send :helper_method, :current_user, :logged_in?, :authorized?, | 132 | base.send :helper_method, :current_user, :logged_in?, :authorized?, |
| 116 | :elevated?, :elevation_expires_at if base.respond_to? :helper_method | 133 | :elevated?, :elevation_expires_at, :elevation_extensions_left if base.respond_to? :helper_method |
| 117 | end | 134 | end |
| 118 | 135 | ||
| 119 | # | 136 | # |
| @@ -147,6 +164,7 @@ module AuthenticatedSystem | |||
| 147 | session[:user_id] = nil # keeps the session but kill our variable | 164 | session[:user_id] = nil # keeps the session but kill our variable |
| 148 | session.delete(:elevated_at) | 165 | session.delete(:elevated_at) |
| 149 | session.delete(:elevation_attempts) | 166 | session.delete(:elevation_attempts) |
| 167 | session.delete(:elevation_extensions) | ||
| 150 | end | 168 | end |
| 151 | 169 | ||
| 152 | # The session should only be reset at the tail end of a form POST -- | 170 | # The session should only be reset at the tail end of a form POST -- |
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 7f286705..be8725d3 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -276,6 +276,46 @@ span.warning a { | |||
| 276 | padding-left: 1.25rem; | 276 | padding-left: 1.25rem; |
| 277 | } | 277 | } |
| 278 | 278 | ||
| 279 | #elevation_banner { | ||
| 280 | display: flex; | ||
| 281 | flex-wrap: wrap; | ||
| 282 | align-items: center; | ||
| 283 | gap: 0.5rem 1rem; | ||
| 284 | margin-bottom: 1rem; | ||
| 285 | padding: 0.5rem 0.75rem; | ||
| 286 | border: 1px solid var(--accent); | ||
| 287 | border-radius: var(--radius); | ||
| 288 | background-color: var(--accent-surface); | ||
| 289 | } | ||
| 290 | |||
| 291 | #elevation_banner .elevation_state { | ||
| 292 | display: inline-flex; | ||
| 293 | align-items: center; | ||
| 294 | gap: 0.35rem; | ||
| 295 | } | ||
| 296 | |||
| 297 | #elevation_banner .elevation_actions { | ||
| 298 | display: inline-flex; | ||
| 299 | align-items: center; | ||
| 300 | gap: 0.5rem; | ||
| 301 | margin-left: auto; | ||
| 302 | } | ||
| 303 | |||
| 304 | #elevation_banner svg { | ||
| 305 | width: 1.25rem; | ||
| 306 | height: 1.25rem; | ||
| 307 | } | ||
| 308 | |||
| 309 | #elevation_banner #elevation_remaining { | ||
| 310 | color: var(--text-muted); | ||
| 311 | } | ||
| 312 | |||
| 313 | #elevation_banner.elevation_soon #elevation_remaining, | ||
| 314 | #elevation_banner.elevation_expired #elevation_remaining { | ||
| 315 | color: var(--accent); | ||
| 316 | font-weight: bold; | ||
| 317 | } | ||
| 318 | |||
| 279 | /* ============================================================ | 319 | /* ============================================================ |
| 280 | Pagination | 320 | Pagination |
| 281 | ============================================================ */ | 321 | ============================================================ */ |
