From 2843790cefc91b46d7676340783bd04692830705 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sun, 7 Jun 2026 18:38:56 +1000 Subject: [PATCH] Apps index access column + summary + admin access checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/access_checks_controller.rb | 25 ++++++ .../admin/applications_controller.rb | 16 +++- app/views/admin/access_checks/new.html.erb | 77 +++++++++++++++++++ app/views/admin/applications/index.html.erb | 26 ++++++- app/views/shared/_sidebar.html.erb | 18 +++++ config/initializers/version.rb | 2 +- config/routes.rb | 2 + .../admin/access_checks_controller_test.rb | 47 +++++++++++ 8 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 app/controllers/admin/access_checks_controller.rb create mode 100644 app/views/admin/access_checks/new.html.erb create mode 100644 test/controllers/admin/access_checks_controller_test.rb diff --git a/app/controllers/admin/access_checks_controller.rb b/app/controllers/admin/access_checks_controller.rb new file mode 100644 index 0000000..de98195 --- /dev/null +++ b/app/controllers/admin/access_checks_controller.rb @@ -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 diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index dc7e8ce..2fc6781 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -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 diff --git a/app/views/admin/access_checks/new.html.erb b/app/views/admin/access_checks/new.html.erb new file mode 100644 index 0000000..c3a5a7b --- /dev/null +++ b/app/views/admin/access_checks/new.html.erb @@ -0,0 +1,77 @@ +
+

Access check

+

Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.

+
+ +
+
+ <%= form_with url: admin_access_path, method: :post, class: "space-y-4" do |form| %> +
+
+ <%= 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" %> +
+
+ <%= 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" %> +
+
+
+ <%= 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" %> +
+ <% end %> + + <% if @user && @application %> +
p-4"> +
+ <% if @allowed %> + + + + <% else %> + + + + <% end %> +
+

"> + <%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>. +

+ <% if @allowed %> +

+ 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 %> +

+ <% else %> +

+ <% 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("; ") %>. +

+ <% end %> +

+ <%= link_to "View user", admin_user_path(@user), class: "underline" %> · + <%= link_to "View application", admin_application_path(@application), class: "underline" %> +

+
+
+
+ <% end %> +
+
diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index 0ee136e..ba2a383 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -8,6 +8,21 @@ +
+
+
Applications
+
<%= @applications.size %>
+
+
+
Users with access
+
<%= @total_users_with_access %>
+
+
+
Groups granting access
+
<%= @total_groups_granting_access %>
+
+
+
@@ -18,7 +33,7 @@ Slug Type Status - Groups + Access Actions @@ -58,10 +73,13 @@ <% end %> - <% if application.allowed_groups.empty? %> - All users + <% groups_count = application.allowed_groups.size %> + <% users_count = @user_count_by_app[application.id] || 0 %> + <% if groups_count.zero? %> + No one <% else %> - <%= application.allowed_groups.count %> + <%= pluralize(users_count, "user") %> + · <%= pluralize(groups_count, "group") %> <% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index 6b9cb90..ffc593e 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -66,6 +66,16 @@ Groups <% end %> + + +
  • + <%= 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 %> + + + + Access check + <% end %> +
  • <% end %> @@ -196,6 +206,14 @@ Groups <% end %> +
  • + <%= 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 %> + + + + Access check + <% end %> +
  • <% end %>
  • <%= 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 %> diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 375a119..db9d8f8 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Clinch - VERSION = "0.14.3" + VERSION = "0.15.0" end diff --git a/config/routes.rb b/config/routes.rb index 78451b5..a208b34 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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) diff --git a/test/controllers/admin/access_checks_controller_test.rb b/test/controllers/admin/access_checks_controller_test.rb new file mode 100644 index 0000000..cd56a8c --- /dev/null +++ b/test/controllers/admin/access_checks_controller_test.rb @@ -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