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:
parent
9bf40690e5
commit
8471570187
13 changed files with 214 additions and 27 deletions
|
|
@ -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`
|
||||
- `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).
|
||||
- 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
|
||||
- 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).
|
||||
|
|
|
|||
38
asxpio.rb
38
asxpio.rb
|
|
@ -136,23 +136,27 @@ class AsxpioWeb < Sinatra::Base
|
|||
get '/admin' do
|
||||
@page_title = 'Dashboard · admin'
|
||||
@noindex = true
|
||||
unpaid = Invoice.where(paid_at: nil).all
|
||||
@inv = {
|
||||
count: Invoice.count,
|
||||
unpaid: unpaid,
|
||||
overdue: unpaid.count { |i| i.due_on < Date.today },
|
||||
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 = {
|
||||
cells: board.cells.size,
|
||||
hit: board.hit.count(true),
|
||||
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)
|
||||
}
|
||||
if can?('invoices', 'read')
|
||||
unpaid = Invoice.where(paid_at: nil).all
|
||||
@inv = {
|
||||
count: Invoice.count,
|
||||
unpaid: unpaid,
|
||||
overdue: unpaid.count { |i| i.due_on < Date.today },
|
||||
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
|
||||
recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all
|
||||
}
|
||||
end
|
||||
if can?('bingo', 'read')
|
||||
board = Bingo::Board.load
|
||||
@bingo = {
|
||||
cells: board.cells.size,
|
||||
hit: board.hit.count(true),
|
||||
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'
|
||||
end
|
||||
|
||||
|
|
|
|||
33
lib/admin_acl.rb
Normal file
33
lib/admin_acl.rb
Normal 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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
require 'rack/utils'
|
||||
require_relative 'rate_limit'
|
||||
require_relative 'admin_acl'
|
||||
|
||||
# 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]
|
||||
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)
|
||||
path == '/admin' || path.start_with?('/admin/')
|
||||
end
|
||||
|
|
@ -67,7 +73,9 @@ module AdminAuth
|
|||
if current_user.must_change_password && !request.path_info.match?(%r{\A/admin/(account/password|logout)\z})
|
||||
redirect '/admin/account/password'
|
||||
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
|
||||
|
||||
app.get '/admin/login' do
|
||||
|
|
|
|||
|
|
@ -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 form { display: inline; }
|
||||
.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:hover { color: var(--text-bright); }
|
||||
.dash { display: grid; gap: 24px; }
|
||||
|
|
|
|||
116
test/admin_acl_test.rb
Normal file
116
test/admin_acl_test.rb
Normal 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
|
||||
|
|
@ -84,7 +84,7 @@ class AdminAuthTest < Minitest::Test
|
|||
assert_equal 302, last_response.status
|
||||
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')
|
||||
login('bob', 'bob-password-12')
|
||||
get '/admin'
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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.')">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue