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]
|
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
||||||
|
|
||||||
def index
|
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
|
end
|
||||||
|
|
||||||
def show
|
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>
|
||||||
</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="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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">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">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">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">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -58,10 +73,13 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<% if application.allowed_groups.empty? %>
|
<% groups_count = application.allowed_groups.size %>
|
||||||
<span class="text-gray-400 dark:text-gray-500">All users</span>
|
<% 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</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 %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
@@ -196,6 +206,14 @@
|
|||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</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 %>
|
<% end %>
|
||||||
<li>
|
<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 %>
|
<%= 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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.14.3"
|
VERSION = "0.15.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :groups
|
resources :groups
|
||||||
|
get "access", to: "access_checks#new"
|
||||||
|
post "access", to: "access_checks#create"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
# 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