admin: user management, invites, password reset, audit log

Add /admin/users to create, edit, disable and delete accounts with the
per-area permission grid. New users get a one-time invite link (48 h)
by email; the admin also sees it once, so an account without email can
still be onboarded. Forgotten passwords get a 1 h reset link with the
same answer for unknown accounts. Only token hashes are stored.

Guards against escalation and lockout: a user manager can only grant
flags they hold and cannot touch flags they lack; only superusers change
superuser accounts; nobody can disable or delete themselves; the last
active superuser stays.

Logins, user changes, and bingo and invoice actions are recorded in an
audit log at /admin/audit.

Read timestamps as UTC: they are all written with Time.now.utc, and on a
UTC+4 host a fresh 1 h token read back as already expired.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 08:31:42 +04:00
commit d91a72f603
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
17 changed files with 686 additions and 0 deletions

View file

@ -24,6 +24,7 @@ require_relative 'lib/db'
require_relative 'lib/admin_auth'
require_relative 'lib/s3'
require_relative 'lib/bingo_admin'
require_relative 'lib/users_admin'
# Invoice model needs a live Sequel connection at class-definition time.
if ENV['DATABASE_URL']
@ -38,6 +39,7 @@ if ENV['DATABASE_URL']
require_relative 'lib/bingo'
Bingo.seed!
require_relative 'lib/user'
require_relative 'lib/audit'
User.bootstrap!(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
else
$logger.warn('DATABASE_URL not set — invoicing disabled')
@ -67,6 +69,7 @@ class AsxpioWeb < Sinatra::Base
use Rack::Protection::AuthenticityToken
register AdminAuth
register BingoAdmin
register UsersAdmin
Mailer.configure!
@ -288,6 +291,7 @@ class AsxpioWeb < Sinatra::Base
retry
end
audit!('invoice.create', invoice.number, "#{invoice.client_name}, #{invoice.currency} #{'%.2f' % invoice.total}")
redirect "/admin/invoices/#{invoice.uuid}"
end
@ -302,6 +306,7 @@ class AsxpioWeb < Sinatra::Base
invoice = Invoice[uuid: params[:uuid]] or halt 404
invoice.paid_at = invoice.paid? ? nil : Time.now.utc
invoice.save_changes
audit!(invoice.paid? ? 'invoice.paid' : 'invoice.unpaid', invoice.number)
redirect "/admin/invoices/#{invoice.uuid}"
end