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

@ -0,0 +1,76 @@
<% fresh = @user.new? %>
<% editable = can?('users', 'write') && (!@user.superuser || current_user.superuser) %>
<% self_edit = !fresh && @user.id == current_user.id %>
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1><%= fresh ? 'New user' : @user.username %></h1>
<p class="tagline"><a href="/admin/users">← users</a></p>
</div>
<% if @error || @flash %><div class="form-error-base"><div><%= @error || @flash %></div></div><% end %>
<section class="section">
<form class="invoice-form" method="post" action="<%= fresh ? '/admin/users' : "/admin/users/#{@user.id}" %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless editable %>>
<div class="invoice-grid">
<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="<%= @user.username %>" <%= fresh ? 'required pattern=[a-z0-9][a-z0-9_.@-]{1,63}' : 'disabled' %> /></div>
<div class="field"><label for="name">Name <span class="optional">(optional)</span></label><input type="text" id="name" name="name" value="<%= @user.name %>" /></div>
<div class="field"><label for="email">Email <span class="optional">(for invite and password reset)</span></label><input type="email" id="email" name="email" value="<%= @user.email %>" /></div>
</div>
<% unless fresh %>
<div class="field"><label><input type="checkbox" name="active" value="1" <%= 'checked' if @user.active %> <%= 'disabled' if self_edit %> /> Active <span class="optional">(unticking logs the user out)</span></label></div>
<% end %>
<% if current_user.superuser %>
<div class="field"><label><input type="checkbox" name="superuser" value="1" id="superuser" <%= 'checked' if @user.superuser %> <%= 'disabled' if self_edit %> /> Superuser <span class="optional">(everything, incl. managing superusers; permissions below are ignored)</span></label></div>
<% end %>
<h2>Permissions</h2>
<table class="invoice-list perm-grid">
<thead><tr><th>Area</th><% User::PERMS.each do |f| %><th><%= f %></th><% end %></tr></thead>
<tbody>
<% User::AREAS.each do |area| %>
<tr>
<td><%= area %></td>
<% User::PERMS.each do |f| %>
<td><input type="checkbox" name="perm[<%= area %>][<%= f %>]" value="1" data-area="<%= area %>" data-flag="<%= f %>"
<%= 'checked' if !fresh && @user.can?(area, f) %> <%= 'disabled' unless current_user.can?(area, f) %> /></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<p class="optional">You can only grant permissions you hold yourself. Delete implies read.</p>
<% if editable %><button type="submit" class="download-button"><%= fresh ? 'Create and send invite' : 'Save' %></button><% end %>
</fieldset>
</form>
</section>
<% if !fresh && editable %>
<section class="section">
<form method="post" action="/admin/users/<%= @user.id %>/invite">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button"><%= @user.password_digest ? '✉ Send password reset link' : '✉ Resend invite' %></button>
</form>
</section>
<% end %>
<% if !fresh && !self_edit && can?('users', 'delete') && (!@user.superuser || current_user.superuser) %>
<section class="section">
<h2>Danger zone</h2>
<form method="post" action="/admin/users/<%= @user.id %>/delete" onsubmit="return confirm('Delete this user? This cannot be undone.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete this user</button>
</form>
</section>
<% end %>
<script>
// Delete implies read: keep the read box ticked when delete is.
document.querySelectorAll('[data-flag="delete"]').forEach(d => d.addEventListener('change', () => {
const r = document.querySelector(`[data-area="${d.dataset.area}"][data-flag="read"]`);
if (d.checked && r && !r.disabled) r.checked = true;
}));
</script>

View file

@ -0,0 +1,37 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Users</h1>
<p class="tagline">
<% if can?('users', 'write') %><a href="/admin/users/new">+ New user</a> · <% end %><a href="/admin/audit">audit log</a>
</p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% if @link %>
<section class="section">
<p><%= @link['kind'] == 'invite' ? 'Invite' : 'Password reset' %> link<%= @link['sent'] ? " emailed to #{@link['to']}" : (@link['to'] ? " — email to #{@link['to']} FAILED, send it yourself" : ' (no email on file — send it yourself)') %>.
Shown once; it works once and expires in <%= @link['kind'] == 'invite' ? '48 hours' : '1 hour' %>:</p>
<p class="mono" style="word-break: break-all"><%= @link['url'] %></p>
</section>
<% end %>
<section class="section">
<table class="invoice-list">
<thead><tr><th>User</th><th>Access</th><th>Status</th><th>Last login</th><th></th></tr></thead>
<tbody>
<% @users.each do |u| %>
<tr>
<td><b><%= u.username %></b><% if u.name.to_s != '' %><br /><span class="optional"><%= u.name %></span><% end %><% if u.email %><br /><span class="optional"><%= u.email %></span><% end %></td>
<td><%= perm_summary(u) %></td>
<td>
<% if !u.active %><span class="badge badge-pending">disabled</span>
<% elsif !u.password_digest %><span class="badge badge-pending">invited</span>
<% else %><span class="badge badge-paid">active</span><% end %>
</td>
<td class="mono"><%= u.last_login_at&.strftime('%Y-%m-%d %H:%M') || '—' %></td>
<td class="actions"><a href="/admin/users/<%= u.id %>"><%= can?('users', 'write') ? 'edit' : 'view' %></a></td>
</tr>
<% end %>
</tbody>
</table>
</section>