From 6df48c1413a14516e7ee8919f33fbc13f0141966 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 1 Aug 2026 02:39:59 +0200 Subject: Show and extend the elevation window A banner appears while elevated, counting down in minutes and carrying extend and drop controls. Three extensions of 30 minutes are allowed, so the window is at most two hours without a fresh code; a code resets the budget. The countdown is advisory, the server-side check authoritative, so it says "expired" rather than vanishing. The post-login flash tells an admin the timer has started. --- app/controllers/elevations_controller.rb | 11 +++++++ app/controllers/otp_challenges_controller.rb | 7 ++++- app/views/layouts/_elevation_banner.html.erb | 47 ++++++++++++++++++++++++++++ app/views/layouts/admin.html.erb | 1 + config/locales/de.yml | 10 ++++++ config/locales/en.yml | 10 ++++++ config/routes.rb | 4 ++- lib/authenticated_system.rb | 20 +++++++++++- public/stylesheets/admin.css | 40 +++++++++++++++++++++++ 9 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 app/views/layouts/_elevation_banner.html.erb 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 flash[:notice] = t("flash.elevation.dropped") redirect_to admin_path end + + def renew + if renew_elevation! + flash[:notice] = t("flash.elevation.renewed", + :minutes => AuthenticatedSystem::ELEVATION_MAX_AGE.in_minutes.to_i, + :left => elevation_extensions_left) + else + flash[:error] = t("flash.elevation.cannot_renew") + end + redirect_back(:fallback_location => admin_path, :allow_other_host => false) + end 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 # an admin who logs in and goes straight to user management # is already elevated elevate! if user.is_admin? - flash[:notice] = t("flash.common.logged_in") + flash[:notice] = if user.is_admin? + t("flash.elevation.granted_at_login", + :minutes => AuthenticatedSystem::ELEVATION_MAX_AGE.in_minutes.to_i) + else + t("flash.common.logged_in") + end redirect_to safe_return_to(return_to, :default => admin_path) else 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 @@ +<% if elevated? %> +
" + data-label-less-than-a-minute="<%= t(".less_than_a_minute") %>" + data-label-expired="<%= t(".expired") %>"> + + <%= icon("shield-check", library: "tabler", "aria-hidden": true) %> + <%= t(".active") %> + + + + <% if elevation_extensions_left > 0 %> + <%= button_to t(".renew"), renew_elevation_path, method: :post, + form: { class: "button_to state_changing" } %> + <% end %> + <%= button_to t(".drop"), elevation_path, method: :delete, + form: { class: "button_to destructive" } %> + +
+ + <%= javascript_tag nonce: true do %> + (function () { + var banner = document.getElementById("elevation_banner"); + if (!banner) return; + var out = document.getElementById("elevation_remaining"); + var expires = parseInt(banner.dataset.expiresAt, 10) * 1000; + + function tick() { + var ms = expires - Date.now(); + if (ms <= 0) { + out.textContent = banner.dataset.labelExpired; + banner.classList.remove("elevation_soon"); + banner.classList.add("elevation_expired"); + return; + } + var mins = Math.floor(ms / 60000); + out.textContent = mins < 1 + ? banner.dataset.labelLessThanAMinute + : banner.dataset.labelMinutes.replace("%{count}", mins); + if (mins < 5) banner.classList.add("elevation_soon"); + setTimeout(tick, 15000); + } + tick(); + })(); + <% end %> +<% 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 @@
+ <%= render "layouts/elevation_banner" %> <%= render "layouts/flash" %>
<%= 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: removed: "%{lang}-Übersetzung aus dem Entwurf entfernt. Veröffentlichen, um das dauerhaft zu machen." elevation: dropped: "Administrative Rechte abgelegt." + renewed: "Administrative Rechte um %{minutes} Minuten verlängert. Noch %{left} Verlängerungen möglich." + cannot_renew: "Verlängern nicht möglich. Rechte müssen mit einem aktuellen Code neu angefordert werden." + granted_at_login: "Angemeldet. Administrative Rechte gelten %{minutes} Minuten." assets: index: @@ -767,3 +770,10 @@ de: log_out: "Abmelden" social_meta: site_description: "Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." + elevation_banner: + active: "Administrative Rechte aktiv, " + minutes_short: "noch %{count} Min." + less_than_a_minute: "weniger als 1 Min." + expired: "abgelaufen" + renew: "Verlängern" + 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: removed: "%{lang} translation removed from the draft. Publish to make this permanent." elevation: dropped: "Administrative rights dropped." + renewed: "Administrative rights extended by %{minutes} minutes. %{left} extensions left." + cannot_renew: "Cannot extend. Request the rights again with a current code." + granted_at_login: "Signed in. Administrative rights last %{minutes} minutes." assets: index: @@ -710,3 +713,10 @@ en: log_out: "Log out" social_meta: 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." + elevation_banner: + active: "Administrative rights active —" + minutes_short: "%{count} min left" + less_than_a_minute: "less than 1 min" + expired: "expired" + renew: "Extend" + 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 end resource :otp_enrollment, :only => [:show, :create, :update, :destroy] resource :otp_challenge, :only => [:new, :create] - resource :elevation, :only => [:new, :create, :destroy] + resource :elevation, :only => [:new, :create, :destroy] do + post :renew + end resources :menu_items, :except => :show do 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 @@ module AuthenticatedSystem SESSION_MAX_AGE = 7.days ELEVATION_MAX_AGE = 30.minutes + MAX_ELEVATION_EXTENSIONS = 3 protected # Returns true or false if the user is logged in. @@ -35,10 +36,26 @@ module AuthenticatedSystem def elevate! session[:elevated_at] = Time.now.to_i + session[:elevation_extensions] = 0 + end + + def elevation_extensions_left + return 0 unless elevated? + MAX_ELEVATION_EXTENSIONS - session[:elevation_extensions].to_i + end + + def renew_elevation! + return false unless elevated? + return false unless elevation_extensions_left > 0 + + session[:elevation_extensions] = session[:elevation_extensions].to_i + 1 + session[:elevated_at] = Time.now.to_i + true end def drop_elevation! session.delete(:elevated_at) + session.delete(:elevation_extensions) end # Check if the user is authorized @@ -113,7 +130,7 @@ module AuthenticatedSystem # available as ActionView helper methods. def self.included(base) base.send :helper_method, :current_user, :logged_in?, :authorized?, - :elevated?, :elevation_expires_at if base.respond_to? :helper_method + :elevated?, :elevation_expires_at, :elevation_extensions_left if base.respond_to? :helper_method end # @@ -147,6 +164,7 @@ module AuthenticatedSystem session[:user_id] = nil # keeps the session but kill our variable session.delete(:elevated_at) session.delete(:elevation_attempts) + session.delete(:elevation_extensions) end # 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 { padding-left: 1.25rem; } +#elevation_banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + margin-bottom: 1rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--accent); + border-radius: var(--radius); + background-color: var(--accent-surface); +} + +#elevation_banner .elevation_state { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +#elevation_banner .elevation_actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +#elevation_banner svg { + width: 1.25rem; + height: 1.25rem; +} + +#elevation_banner #elevation_remaining { + color: var(--text-muted); +} + +#elevation_banner.elevation_soon #elevation_remaining, +#elevation_banner.elevation_expired #elevation_remaining { + color: var(--accent); + font-weight: bold; +} + /* ============================================================ Pagination ============================================================ */ -- cgit v1.3