Apps index access column + summary + admin access checker
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

The Applications index used to render "All users" whenever an app had
no allowed_groups; under default-deny that's the opposite of the truth.
Replaced with a "No one" badge and, when groups are present, a
"N users · M groups" cell so the access reality is visible at a glance.

Added a small stats strip above the apps table: applications, users
with access, and groups granting access. Backed by preloaded counts in
the controller to avoid N+1.

Added /admin/access — a small "Access check" tool that takes a user
and an application and reports whether the user can reach it, with the
granting group(s) when allowed, and the specific reason when not
(inactive app/user, no allowed groups, or no shared group). Wired into
the admin sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-07 18:38:56 +10:00
parent 0e9ec71013
commit 2843790cef
8 changed files with 207 additions and 6 deletions

View File

@@ -0,0 +1,25 @@
module Admin
class AccessChecksController < BaseController
def new
load_options
end
def create
load_options
@user = User.find_by(id: params[:user_id])
@application = Application.find_by(id: params[:application_id])
return render :new unless @user && @application
@allowed = @application.user_allowed?(@user)
@via = @user.groups & @application.allowed_groups
render :new
end
private
def load_options
@users = User.order(:email_address)
@applications = Application.order(:name)
end
end
end

View File

@@ -3,7 +3,21 @@ module Admin
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
def index
@applications = Application.order(created_at: :desc)
@applications = Application.order(created_at: :desc).includes(:allowed_groups)
# Distinct active users that have access to each app, preloaded to avoid N+1.
@user_count_by_app = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.group("applications.id")
.distinct
.count("users.id")
# Top-of-page summary
@total_users_with_access = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.distinct
.count("users.id")
@total_groups_granting_access = Group.joins(:applications).distinct.count
end
def show

View File

@@ -0,0 +1,77 @@
<div class="mb-6">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
</div>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with url: admin_access_path, method: :post, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :user_id,
@users.map { |u| [u.email_address, u.id] },
{ include_blank: "Select a user…", selected: @user&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :application_id,
@applications.map { |a| [a.name, a.id] },
{ include_blank: "Select an application…", selected: @application&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
</div>
<div>
<%= form.submit "Check access", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
<% if @user && @application %>
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
<div class="flex items-start gap-3">
<% if @allowed %>
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<% else %>
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<% end %>
<div class="flex-1">
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
</p>
<% if @allowed %>
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
Granted via:
<% @via.each_with_index do |g, i| %>
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
<% end %>
</p>
<% else %>
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
<% reasons = [] %>
<% reasons << "the application is inactive" unless @application.active? %>
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
<% if @application.active? && @user.active? %>
<% if @application.allowed_groups.empty? %>
<% reasons << "the application has no allowed groups (default deny)" %>
<% else %>
<% reasons << "the user shares no group with the application's allowed groups" %>
<% end %>
<% end %>
Reason: <%= reasons.join("; ") %>.
</p>
<% end %>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -8,6 +8,21 @@
</div>
</div>
<dl class="mt-4 grid grid-cols-3 gap-4">
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
</div>
</dl>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@@ -18,7 +33,7 @@
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
@@ -58,10 +73,13 @@
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if application.allowed_groups.empty? %>
<span class="text-gray-400 dark:text-gray-500">All users</span>
<% groups_count = application.allowed_groups.size %>
<% users_count = @user_count_by_app[application.id] || 0 %>
<% if groups_count.zero? %>
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
<% else %>
<%= application.allowed_groups.count %>
<span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
<% end %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -66,6 +66,16 @@
Groups
<% end %>
</li>
<!-- Admin: Access check -->
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %>
<!-- Profile -->
@@ -196,6 +206,14 @@
Groups
<% end %>
</li>
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %>
<li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.14.3"
VERSION = "0.15.0"
end

View File

@@ -95,6 +95,8 @@ Rails.application.routes.draw do
end
end
resources :groups
get "access", to: "access_checks#new"
post "access", to: "access_checks#create"
end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)

View File

@@ -0,0 +1,47 @@
require "test_helper"
module Admin
class AccessChecksControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@kavita = applications(:kavita_app)
end
test "new renders the form with users and applications" do
get admin_access_path
assert_response :success
assert_match @kavita.name, response.body
assert_match "alice@example.com", response.body
end
test "create returns 'can access' with via group when user is in an allowed group" do
post admin_access_path, params: {
user_id: users(:alice).id,
application_id: @kavita.id
}
assert_response :success
assert_match "can access", response.body
assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group
end
test "create returns 'cannot access' with reason when user shares no group with the app" do
lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true)
post admin_access_path, params: {
user_id: lonely.id,
application_id: @kavita.id
}
assert_response :success
assert_match "cannot access", response.body
assert_match "shares no group", response.body
end
test "create renders form unchanged when ids are missing" do
post admin_access_path, params: {user_id: "", application_id: ""}
assert_response :success
# No result panel should render. The panel-only phrases:
refute_match "Granted via", response.body
refute_match "Reason:", response.body
end
end
end