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:
parent
8471570187
commit
d91a72f603
17 changed files with 686 additions and 0 deletions
23
views/admin/audit.erb
Normal file
23
views/admin/audit.erb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<%== erb :'admin/_nav', layout: false %>
|
||||
<div class="header">
|
||||
<h1>Audit log</h1>
|
||||
<p class="tagline"><a href="/admin/users">← users</a> · last <%= @entries.size %> entries</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<table class="invoice-list">
|
||||
<thead><tr><th>Time (UTC)</th><th>User</th><th>Action</th><th>Target</th><th>Detail</th><th>IP</th></tr></thead>
|
||||
<tbody>
|
||||
<% @entries.each do |e| %>
|
||||
<tr>
|
||||
<td class="mono"><%= e[:created_at].strftime('%Y-%m-%d %H:%M') %></td>
|
||||
<td><%= e[:username] || '—' %></td>
|
||||
<td class="mono"><%= e[:action] %></td>
|
||||
<td><%= e[:target] %></td>
|
||||
<td class="optional"><%= e[:detail] %></td>
|
||||
<td class="mono optional"><%= e[:ip] %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
@ -12,4 +12,5 @@
|
|||
<div class="field"><label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required /></div>
|
||||
<button type="submit" class="download-button">Log in</button>
|
||||
</form>
|
||||
<p class="optional" style="text-align: center"><a href="/admin/reset">Forgot password?</a></p>
|
||||
</section>
|
||||
|
|
|
|||
16
views/admin/reset_request.erb
Normal file
16
views/admin/reset_request.erb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<div class="header">
|
||||
<h1>Reset password</h1>
|
||||
<p class="tagline"><a href="/admin/login">← log in</a></p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<% if @sent %>
|
||||
<p>If an account with an email address matches, a reset link is on its way. It works once and expires in 1 hour.</p>
|
||||
<% else %>
|
||||
<form class="invoice-form auth-form" method="post" action="/admin/reset">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<div class="field"><label for="login">Username or email</label><input type="text" id="login" name="login" autocomplete="username" autocapitalize="none" required autofocus /></div>
|
||||
<button type="submit" class="download-button">Send reset link</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</section>
|
||||
15
views/admin/set_password.erb
Normal file
15
views/admin/set_password.erb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div class="header">
|
||||
<h1><%= @purpose == 'invite' ? 'Welcome' : 'Reset password' %></h1>
|
||||
<p class="tagline">Set a password for <b><%= @token.user.username %></b></p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<% if @error %><div class="form-error-base"><div><%= @error %></div></div><% end %>
|
||||
<form class="invoice-form auth-form" method="post" action="/admin/<%= @purpose %>/<%= params[:token] %>">
|
||||
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||
<input type="text" name="username" value="<%= @token.user.username %>" autocomplete="username" hidden />
|
||||
<div class="field"><label for="password">Password <span class="optional">(at least <%= User::MIN_PASS %> characters)</span></label><input type="password" id="password" name="password" autocomplete="new-password" minlength="<%= User::MIN_PASS %>" required autofocus /></div>
|
||||
<div class="field"><label for="confirm">Repeat password</label><input type="password" id="confirm" name="confirm" autocomplete="new-password" required /></div>
|
||||
<button type="submit" class="download-button">Set password and log in</button>
|
||||
</form>
|
||||
</section>
|
||||
76
views/admin/users/form.erb
Normal file
76
views/admin/users/form.erb
Normal 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>
|
||||
37
views/admin/users/index.erb
Normal file
37
views/admin/users/index.erb
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue