admin: per-area permissions on every route

Give each user independent read, write and delete flags per area
(invoices, bingo, users), so someone can e.g. review and remove bingo
incidents without being able to add any. The request method and path
decide the flag needed: GET is read except the new/duplicate forms, POST
to .../delete is delete, any other POST is write. Unmapped admin paths
are superuser-only, and a test enumerates every registered admin route
so a new one cannot ship without a rule.

Pages follow the same flags: nav and dashboard tiles show readable areas
only, write forms and delete buttons are hidden, and the datacenter form
is shown read-only.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 08:27:37 +04:00
commit 8471570187
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
13 changed files with 214 additions and 27 deletions

View file

@ -1,5 +1,6 @@
<nav class="admin-nav">
<% [['/admin', 'dashboard'], ['/admin/invoices', 'invoices'], ['/admin/bingo', 'bingo']].each do |href, label| %>
<% [['/admin', 'dashboard', nil], ['/admin/invoices', 'invoices', 'invoices'], ['/admin/bingo', 'bingo', 'bingo'], ['/admin/users', 'users', 'users']].each do |href, label, area| %>
<% next if area && !can?(area, 'read') %>
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
<% end %>
<% if current_user %>

View file

@ -11,17 +11,21 @@
<h2>Photo</h2>
<% if @dc.photo_file %>
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
<% if can?('bingo', 'delete') %>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">remove from card</button>
</form>
<% end %>
<% end %>
<% if can?('bingo', 'write') %>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="field"><label for="photo">Upload <span class="optional">(JPEG/PNG/WebP, ≤ 8 MB)</span></label><input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" /></div>
<div class="field"><label for="photo_url">…or import from URL <span class="optional">(blank = saved source URL)</span></label><input type="url" id="photo_url" name="url" placeholder="<%= @dc.photo_source_url %>" /></div>
<button type="submit" class="download-button">Store photo</button>
</form>
<% end %>
</section>
<% end %>
@ -29,6 +33,7 @@
<h2>Details</h2>
<form class="invoice-form" method="post" action="<%= fresh ? '/admin/bingo/dc' : "/admin/bingo/dc/#{@dc.id}" %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('bingo', 'write') %>>
<% if fresh %>
<div class="field"><label for="slug">Slug <span class="optional">(permanent; used in photo names, e.g. ix-mos7)</span></label><input type="text" id="slug" name="slug" value="<%= @dc.slug %>" required pattern="[a-z0-9]+(-[a-z0-9]+)*" /></div>
<% end %>
@ -60,11 +65,12 @@
<div class="field"><label for="photo_credit">Photo credit</label><input type="text" id="photo_credit" name="photo_credit" value="<%= @dc.photo_credit %>" /></div>
<div class="field"><label for="photo_source_url">Photo source URL</label><input type="url" id="photo_source_url" name="photo_source_url" value="<%= @dc.photo_source_url %>" /></div>
<div class="field"><label for="info_url">Info / fact source URL</label><input type="url" id="info_url" name="info_url" value="<%= @dc.info_url %>" /></div>
<button type="submit" class="download-button"><%= fresh ? 'Add datacenter' : 'Save' %></button>
<% if can?('bingo', 'write') %><button type="submit" class="download-button"><%= fresh ? 'Add datacenter' : 'Save' %></button><% end %>
</fieldset>
</form>
</section>
<% unless fresh %>
<% if !fresh && can?('bingo', 'delete') %>
<section class="section">
<h2>Danger zone</h2>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/delete" onsubmit="return confirm('Delete this datacenter and all its incidents? This cannot be undone.')">

View file

@ -8,6 +8,7 @@
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% if can?('bingo', 'write') %>
<section class="section">
<h2>Record incident</h2>
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off">
@ -37,6 +38,7 @@
<button type="submit" class="download-button">Cross it out</button>
</form>
</section>
<% end %>
<section class="section">
<h2>Incidents</h2>
@ -53,10 +55,12 @@
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td>
<td><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
<td class="actions">
<% if can?('bingo', 'delete') %>
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete</button>
</form>
<% end %>
</td>
</tr>
<% end %>
@ -67,6 +71,7 @@
<section class="section">
<h2>Card</h2>
<% if can?('bingo', 'write') %>
<p><a href="/admin/bingo/dc/new">+ Add datacenter</a> · positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve</p>
<form method="post" action="/admin/bingo/sort" onsubmit="return confirm('Re-rank every datacenter by MW? Manual ordering is lost.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
@ -85,6 +90,9 @@
<input type="hidden" name="all" value="1" />
<button type="submit" class="link-button">↻ Re-import all photos from their source URLs</button>
</form>
<% else %>
<p class="optional">Positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve.</p>
<% end %>
<table class="invoice-list">
<thead><tr><th>#</th><th>Photo</th><th>Name</th><th>Operator</th><th class="num">MW</th><th>Status</th><th class="num">Incidents</th><th></th></tr></thead>
<tbody>
@ -100,7 +108,7 @@
<td class="num mono"><%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %></td>
<td><%= dc.status %></td>
<td class="num mono"><%= dc.incidents.size %></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>"><%= can?('bingo', 'write') ? 'edit' : 'view' %></a></td>
</tr>
<% end %>
</tbody>

View file

@ -4,6 +4,7 @@
</div>
<div class="dash">
<% if @inv %>
<section class="section dash-tile">
<h2><a href="/admin/invoices">Invoices</a></h2>
<div class="stats">
@ -28,9 +29,11 @@
</tbody>
</table>
<% end %>
<p><a href="/admin/invoices/new">+ New invoice</a> · <a href="/admin/invoices">all invoices</a></p>
<p><% if can?('invoices', 'write') %><a href="/admin/invoices/new">+ New invoice</a> · <% end %><a href="/admin/invoices">all invoices</a></p>
</section>
<% end %>
<% if @bingo %>
<section class="section dash-tile">
<h2><a href="/admin/bingo">Bingo</a></h2>
<div class="stats">
@ -46,4 +49,8 @@
<% end %>
<p><a href="/admin/bingo">manage</a><% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %></p>
</section>
<% end %>
<% unless @inv || @bingo %>
<section class="section"><p>No areas are assigned to your account yet. Ask an administrator for access.</p></section>
<% end %>
</div>

View file

@ -1,12 +1,12 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Invoices</h1>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p>
<% if can?('invoices', 'write') %><p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p><% end %>
</div>
<% if @invoices.empty? %>
<section class="section">
<p>No invoices yet. <a href="/admin/invoices/new">Create the first one</a>.</p>
<p>No invoices yet.<% if can?('invoices', 'write') %> <a href="/admin/invoices/new">Create the first one</a>.<% end %></p>
</section>
<% else %>
<section class="section">

View file

@ -34,14 +34,16 @@
Share this URL with the client:<br />
<code class="mono">https://asxp.io/i/<%= @invoice.uuid %></code>
</p>
<% if can?('invoices', 'write') %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid" style="margin-top: 14px;">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit">
<% if @invoice.paid? %>Mark unpaid<% else %>Mark paid<% end %>
</button>
</form>
<% end %>
<p style="margin-top: 14px;">
<a href="/admin/invoices/<%= @invoice.uuid %>/duplicate">Use as template for a new invoice →</a>
<% if can?('invoices', 'write') %><a href="/admin/invoices/<%= @invoice.uuid %>/duplicate">Use as template for a new invoice →</a><% end %>
</p>
</section>