Apps index access column + summary + admin access checker
Some checks failed
Some checks failed
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:
25
app/controllers/admin/access_checks_controller.rb
Normal file
25
app/controllers/admin/access_checks_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
77
app/views/admin/access_checks/new.html.erb
Normal file
77
app/views/admin/access_checks/new.html.erb
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.14.3"
|
||||
VERSION = "0.15.0"
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
47
test/controllers/admin/access_checks_controller_test.rb
Normal file
47
test/controllers/admin/access_checks_controller_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user