summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-01 02:39:59 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-01 02:39:59 +0200
commit6df48c1413a14516e7ee8919f33fbc13f0141966 (patch)
treed81df761eb6607a6f4508241c043375e9860679d /app
parentf6c1f0f08f031778a491465d35ac694bfcdc12b0 (diff)
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.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/elevations_controller.rb11
-rw-r--r--app/controllers/otp_challenges_controller.rb7
-rw-r--r--app/views/layouts/_elevation_banner.html.erb47
-rw-r--r--app/views/layouts/admin.html.erb1
4 files changed, 65 insertions, 1 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
44end 55end
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 %>