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

@ -37,6 +37,7 @@ Banking details and registration number are deliberately not on the public page
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb` - In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token) - `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
- `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min). - `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min).
- Permissions (`lib/admin_acl.rb`): areas `invoices`, `bingo`, `users` × independent flags read/write/delete in `user_permissions` (read + delete without write is valid); superusers bypass. GET → read (…/new, …/duplicate → write), POST …/delete|…/disable → delete, other POST → write. Unmapped /admin paths are superuser-only, and `test/admin_acl_test.rb` fails if a registered admin route isn't mapped — add new admin routes to `AdminACL::RULES`. Views hide forms via `can?(area, perm)`; the server check is the real gate.
- Honeypot field named `website` for spam - Honeypot field named `website` for spam
- Static assets in `public/`; views in `views/` - Static assets in `public/`; views in `views/`
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO). - Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO).

View file

@ -136,23 +136,27 @@ class AsxpioWeb < Sinatra::Base
get '/admin' do get '/admin' do
@page_title = 'Dashboard · admin' @page_title = 'Dashboard · admin'
@noindex = true @noindex = true
unpaid = Invoice.where(paid_at: nil).all if can?('invoices', 'read')
@inv = { unpaid = Invoice.where(paid_at: nil).all
count: Invoice.count, @inv = {
unpaid: unpaid, count: Invoice.count,
overdue: unpaid.count { |i| i.due_on < Date.today }, unpaid: unpaid,
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) }, overdue: unpaid.count { |i| i.due_on < Date.today },
recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
} recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all
board = Bingo::Board.load }
@bingo = { end
cells: board.cells.size, if can?('bingo', 'read')
hit: board.hit.count(true), board = Bingo::Board.load
lines: board.wins.size, @bingo = {
reserve: [Bingo::Datacenter.count - board.cells.size, 0].max, cells: board.cells.size,
last_incident: board.incidents.first, hit: board.hit.count(true),
no_photo: board.cells.reject(&:photo_key) lines: board.wins.size,
} reserve: [Bingo::Datacenter.count - board.cells.size, 0].max,
last_incident: board.incidents.first,
no_photo: board.cells.reject(&:photo_key)
}
end
erb :'admin/dashboard' erb :'admin/dashboard'
end end

33
lib/admin_acl.rb Normal file
View file

@ -0,0 +1,33 @@
# Which permission an /admin request needs. Every admin path must map to
# an area (or be public/open); unmapped paths are superuser-only, and a
# test fails if any registered route is unmapped.
#
# GET/HEAD → read (except …/new and …/duplicate: write)
# POST …/delete, …/disable → delete
# any other non-GET → write
module AdminACL
RULES = [
[%r{\A/admin/(invoices|gel-rate|crypto-rate)(/|\z)}, 'invoices'],
[%r{\A/admin/bingo(/|\z)}, 'bingo'],
[%r{\A/admin/(users|audit)(/|\z)}, 'users'],
].freeze
module_function
def area_for(path)
RULES.find { |re, _| path.match?(re) }&.last
end
def perm_for(method, path)
get = %w[GET HEAD].include?(method)
return 'delete' if !get && path.match?(%r{/(delete|disable)\z})
return 'write' if !get || path.match?(%r{/(new|duplicate)\z})
'read'
end
def allowed?(user, method, path)
return true if user.superuser || path.match?(AdminAuth::OPEN)
area = area_for(path) or return false
user.can?(area, perm_for(method, path))
end
end

View file

@ -1,5 +1,6 @@
require 'rack/utils' require 'rack/utils'
require_relative 'rate_limit' require_relative 'rate_limit'
require_relative 'admin_acl'
# Session login for /admin/*, replacing the old shared HTTP Basic gate. # Session login for /admin/*, replacing the old shared HTTP Basic gate.
# #
@ -29,6 +30,11 @@ module AdminAuth
@current_user = user if user&.active && user.session_version == session[:uv] @current_user = user if user&.active && user.session_version == session[:uv]
end end
# For views: show a form/button only if the user may use it.
def can?(area, perm)
current_user&.can?(area, perm) || false
end
def admin_path?(path = request.path_info) def admin_path?(path = request.path_info)
path == '/admin' || path.start_with?('/admin/') path == '/admin' || path.start_with?('/admin/')
end end
@ -67,7 +73,9 @@ module AdminAuth
if current_user.must_change_password && !request.path_info.match?(%r{\A/admin/(account/password|logout)\z}) if current_user.must_change_password && !request.path_info.match?(%r{\A/admin/(account/password|logout)\z})
redirect '/admin/account/password' redirect '/admin/account/password'
end end
halt 403, 'Forbidden' unless current_user.superuser || request.path_info.match?(OPEN) unless AdminACL.allowed?(current_user, request.request_method, request.path_info)
halt 403, 'You do not have access to this.'
end
end end
app.get '/admin/login' do app.get '/admin/login' do

View file

@ -359,6 +359,7 @@ a:hover {
.admin-nav-user { display: flex; gap: 8px; align-items: baseline; margin-left: 16px; padding-left: 16px; border-left: 1px solid var(--border); } .admin-nav-user { display: flex; gap: 8px; align-items: baseline; margin-left: 16px; padding-left: 16px; border-left: 1px solid var(--border); }
.admin-nav-user form { display: inline; } .admin-nav-user form { display: inline; }
.auth-form { max-width: 360px; margin: 0 auto; } .auth-form { max-width: 360px; margin: 0 auto; }
fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; }
.admin-nav a.active, .admin-nav a.active,
.admin-nav a:hover { color: var(--text-bright); } .admin-nav a:hover { color: var(--text-bright); }
.dash { display: grid; gap: 24px; } .dash { display: grid; gap: 24px; }

116
test/admin_acl_test.rb Normal file
View file

@ -0,0 +1,116 @@
require_relative 'test_helper'
class AdminAclTest < Minitest::Test
include Rack::Test::Methods
include AdminLogin
def app
AsxpioWeb
end
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
TestDb.clean_users!
DB.connection[:bingo_incidents].delete
@dc = Bingo::Datacenter.order(:position).first ||
Bingo::Datacenter.create(slug: 'acl-dc', position: 1, name_ru: 'ЦОД', city_ru: 'Москва', operator: 'Op')
end
def as(perms)
u = User.create(username: 'user1', password: 'user-password-1')
perms.each do |area, flags|
UserPermission.create(user_id: u.id, area: area.to_s,
**flags.to_h { |f| [:"can_#{f}", true] })
end
admin_login!(username: 'user1', password: 'user-password-1')
end
def token
get '/admin'
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end
def forbidden?
last_response.status == 403
end
# Every registered /admin route is public, open, or mapped to an area.
def test_every_admin_route_is_mapped
unmapped = AsxpioWeb.routes.flat_map do |verb, routes|
routes.map { |r| [verb, r.first.to_s] }
end.select { |_, p| p.start_with?('/admin') }.reject do |_, p|
sample = p.gsub(/:\w+/, 'x')
sample.match?(AdminAuth::PUBLIC) || sample.match?(AdminAuth::OPEN) || AdminACL.area_for(sample)
end
assert_empty unmapped, "unmapped admin routes: #{unmapped.inspect}"
end
def test_perm_for
assert_equal 'read', AdminACL.perm_for('GET', '/admin/bingo')
assert_equal 'write', AdminACL.perm_for('GET', '/admin/bingo/dc/new')
assert_equal 'write', AdminACL.perm_for('GET', '/admin/invoices/abc/duplicate')
assert_equal 'write', AdminACL.perm_for('POST', '/admin/bingo/dc/1')
assert_equal 'delete', AdminACL.perm_for('POST', '/admin/bingo/incidents/1/delete')
assert_equal 'delete', AdminACL.perm_for('POST', '/admin/bingo/dc/1/photo/delete')
end
def test_bingo_read_only
as(bingo: %i[read])
get '/admin/bingo'
assert last_response.ok?
refute_includes last_response.body, 'Record incident'
refute_includes last_response.body, 'href="/admin/invoices"' # nav hides invoices
get "/admin/bingo/dc/#{@dc.id}"
assert last_response.ok?
assert_includes last_response.body, 'disabled'
refute_includes last_response.body, 'Danger zone'
get '/admin/bingo/dc/new'
assert forbidden?
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dc.id, occurred_on: '2026-10-01', kind: 'fire', title_ru: 'x' }
assert forbidden?
get '/admin/invoices'
assert forbidden?
end
def test_read_and_delete_without_write
inc = Bingo::Incident.create(datacenter_id: @dc.id, occurred_on: Date.today, kind: 'fire', title_ru: 'x')
as(bingo: %i[read delete])
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dc.id, occurred_on: '2026-10-01', kind: 'fire', title_ru: 'y' }
assert forbidden?
get '/admin/bingo'
assert_includes last_response.body, "/admin/bingo/incidents/#{inc.id}/delete"
post "/admin/bingo/incidents/#{inc.id}/delete", { authenticity_token: token }
refute forbidden?
assert_nil Bingo::Incident[inc.id]
end
def test_invoices_read_only
as(invoices: %i[read])
get '/admin/invoices'
assert last_response.ok?
refute_includes last_response.body, '+ New invoice'
get '/admin/invoices/new'
assert forbidden?
post '/admin/invoices', { authenticity_token: token }
assert forbidden?
get '/admin/bingo'
assert forbidden?
get '/admin'
refute_includes last_response.body, '<h2><a href="/admin/bingo">'
end
def test_user_without_areas_sees_notice
as({})
get '/admin'
assert last_response.ok?
assert_includes last_response.body, 'No areas are assigned'
end
def test_superuser_sees_everything
admin_login!
get '/admin/bingo/dc/new'
assert last_response.ok?
get '/admin/invoices/new'
assert last_response.ok?
end
end

View file

@ -84,7 +84,7 @@ class AdminAuthTest < Minitest::Test
assert_equal 302, last_response.status assert_equal 302, last_response.status
end end
def test_non_superuser_limited_to_open_pages_in_phase_one def test_user_without_permissions_limited_to_open_pages
User.create(username: 'bob', password: 'bob-password-12') User.create(username: 'bob', password: 'bob-password-12')
login('bob', 'bob-password-12') login('bob', 'bob-password-12')
get '/admin' get '/admin'

View file

@ -1,5 +1,6 @@
<nav class="admin-nav"> <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> <a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
<% end %> <% end %>
<% if current_user %> <% if current_user %>

View file

@ -11,17 +11,21 @@
<h2>Photo</h2> <h2>Photo</h2>
<% if @dc.photo_file %> <% if @dc.photo_file %>
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p> <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"> <form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">remove from card</button> <button type="submit" class="link-button">remove from card</button>
</form> </form>
<% end %>
<% end %> <% end %>
<% if can?('bingo', 'write') %>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data"> <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 %>" /> <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">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> <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> <button type="submit" class="download-button">Store photo</button>
</form> </form>
<% end %>
</section> </section>
<% end %> <% end %>
@ -29,6 +33,7 @@
<h2>Details</h2> <h2>Details</h2>
<form class="invoice-form" method="post" action="<%= fresh ? '/admin/bingo/dc' : "/admin/bingo/dc/#{@dc.id}" %>" autocomplete="off"> <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 %>" /> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('bingo', 'write') %>>
<% if fresh %> <% 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> <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 %> <% 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_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="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> <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> </form>
</section> </section>
<% unless fresh %> <% if !fresh && can?('bingo', 'delete') %>
<section class="section"> <section class="section">
<h2>Danger zone</h2> <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.')"> <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 @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% if can?('bingo', 'write') %>
<section class="section"> <section class="section">
<h2>Record incident</h2> <h2>Record incident</h2>
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off"> <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> <button type="submit" class="download-button">Cross it out</button>
</form> </form>
</section> </section>
<% end %>
<section class="section"> <section class="section">
<h2>Incidents</h2> <h2>Incidents</h2>
@ -53,10 +55,12 @@
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td> <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><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
<td class="actions"> <td class="actions">
<% if can?('bingo', 'delete') %>
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')"> <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 %>" /> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete</button> <button type="submit" class="link-button">delete</button>
</form> </form>
<% end %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
@ -67,6 +71,7 @@
<section class="section"> <section class="section">
<h2>Card</h2> <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> <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.')"> <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 %>" /> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
@ -85,6 +90,9 @@
<input type="hidden" name="all" value="1" /> <input type="hidden" name="all" value="1" />
<button type="submit" class="link-button">↻ Re-import all photos from their source URLs</button> <button type="submit" class="link-button">↻ Re-import all photos from their source URLs</button>
</form> </form>
<% else %>
<p class="optional">Positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve.</p>
<% end %>
<table class="invoice-list"> <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> <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> <tbody>
@ -100,7 +108,7 @@
<td class="num mono"><%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %></td> <td class="num mono"><%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %></td>
<td><%= dc.status %></td> <td><%= dc.status %></td>
<td class="num mono"><%= dc.incidents.size %></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> </tr>
<% end %> <% end %>
</tbody> </tbody>

View file

@ -4,6 +4,7 @@
</div> </div>
<div class="dash"> <div class="dash">
<% if @inv %>
<section class="section dash-tile"> <section class="section dash-tile">
<h2><a href="/admin/invoices">Invoices</a></h2> <h2><a href="/admin/invoices">Invoices</a></h2>
<div class="stats"> <div class="stats">
@ -28,9 +29,11 @@
</tbody> </tbody>
</table> </table>
<% end %> <% 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> </section>
<% end %>
<% if @bingo %>
<section class="section dash-tile"> <section class="section dash-tile">
<h2><a href="/admin/bingo">Bingo</a></h2> <h2><a href="/admin/bingo">Bingo</a></h2>
<div class="stats"> <div class="stats">
@ -46,4 +49,8 @@
<% end %> <% 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> <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> </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> </div>

View file

@ -1,12 +1,12 @@
<%== erb :'admin/_nav', layout: false %> <%== erb :'admin/_nav', layout: false %>
<div class="header"> <div class="header">
<h1>Invoices</h1> <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> </div>
<% if @invoices.empty? %> <% if @invoices.empty? %>
<section class="section"> <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> </section>
<% else %> <% else %>
<section class="section"> <section class="section">

View file

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