Default-deny access control with group flags and access enumeration
Some checks failed
Some checks failed
Replaces the implicit "empty allowed_groups means public" rule with explicit default-deny across both OIDC and ForwardAuth. Adds two boolean flags on Group — auto_assign (Keycloak-style auto-join on user create) and admin (members can reach the admin panel) — and drops the users.admin column entirely. Adds "Users with access" and "Accessible applications" panels with via-group badges on the application/user show pages. BEHAVIOR CHANGE: a ForwardAuth app with no allowed_groups previously bypassed authentication entirely; it now returns 403 like any other unauthorized request. The data migration seeds an "everyone" group and attaches it to all previously group-less apps to preserve behavior on existing installs. An "admins" group is seeded and backfilled from any user with the old admin column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ module Admin
|
||||
|
||||
def show
|
||||
@allowed_groups = @application.allowed_groups
|
||||
@users_with_access = User.where(status: User.statuses[:active])
|
||||
.joins(groups: :applications)
|
||||
.where(applications: {id: @application.id})
|
||||
.distinct
|
||||
.includes(:groups)
|
||||
.order(:email_address)
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -122,7 +122,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description, :custom_claims)
|
||||
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,27 +7,38 @@ module Admin
|
||||
end
|
||||
|
||||
def show
|
||||
@accessible_applications = Application.where(active: true)
|
||||
.joins(:allowed_groups)
|
||||
.where(groups: {id: @user.groups})
|
||||
.distinct
|
||||
.includes(:allowed_groups)
|
||||
.order(:name)
|
||||
end
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||
@user.status = :pending_invitation
|
||||
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
||||
|
||||
if @user.save
|
||||
assign_groups_from_params(@user)
|
||||
InvitationsMailer.invite_user(@user).deliver_later
|
||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -43,6 +54,7 @@ module Admin
|
||||
rescue JSON::ParserError
|
||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -52,9 +64,16 @@ module Admin
|
||||
end
|
||||
|
||||
if @user.update(update_params)
|
||||
unless assign_groups_from_params(@user)
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
redirect_to admin_users_path, notice: "User updated successfully."
|
||||
else
|
||||
@applications = Application.active.order(:name)
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -122,14 +141,28 @@ module Admin
|
||||
end
|
||||
|
||||
def user_params
|
||||
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims]
|
||||
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||
end
|
||||
|
||||
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
||||
if params[:id] != Current.session.user.id.to_s
|
||||
permitted << :admin
|
||||
# Apply group_ids from the form, with a guard preventing self-demotion when
|
||||
# the user is the last member of the last admin group. Returns true on
|
||||
# success, false if a guard fired (caller should re-render).
|
||||
def assign_groups_from_params(user)
|
||||
return true unless params[:user].key?(:group_ids)
|
||||
|
||||
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
|
||||
new_groups = Group.where(id: raw_ids)
|
||||
|
||||
if user == Current.session.user
|
||||
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
|
||||
if losing_admin
|
||||
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
params.require(:user).permit(*permitted)
|
||||
user.groups = new_groups
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,12 +8,16 @@ class UsersController < ApplicationController
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
# First user becomes admin automatically
|
||||
@user.admin = true if User.count.zero?
|
||||
@user.status = "active"
|
||||
first_user = User.count.zero?
|
||||
|
||||
if @user.save
|
||||
# First user automatically becomes a member of every admin group, so they
|
||||
# can reach the admin panel without an existing admin to grant access.
|
||||
if first_user
|
||||
Group.where(admin: true).each { |g| @user.groups << g }
|
||||
end
|
||||
|
||||
start_new_session_for @user
|
||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||
else
|
||||
|
||||
@@ -118,14 +118,12 @@ class Application < ApplicationRecord
|
||||
end
|
||||
|
||||
# Access control
|
||||
# Default-deny: an empty allowed_groups list means no one gets in.
|
||||
# To make an app accessible to "everyone", attach the seeded auto-assign
|
||||
# group (or any group every user is in).
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
@@ -168,10 +166,6 @@ class Application < ApplicationRecord
|
||||
return "deny" unless active?
|
||||
return "deny" unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return "bypass" if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||
|
||||
@@ -15,6 +15,11 @@ class Group < ApplicationRecord
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
scope :auto_assign, -> { where(auto_assign: true) }
|
||||
scope :admin, -> { where(admin: true) }
|
||||
|
||||
before_destroy :ensure_other_admin_group_exists
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
return {} if custom_claims.blank?
|
||||
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_other_admin_group_exists
|
||||
return unless admin?
|
||||
return if Group.where(admin: true).where.not(id: id).exists?
|
||||
errors.add(:base, "cannot delete the last administrators group")
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
|
||||
@@ -42,7 +42,18 @@ class User < ApplicationRecord
|
||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||
|
||||
# Scopes
|
||||
scope :admins, -> { where(admin: true) }
|
||||
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
|
||||
|
||||
# Set true on a user (or on the user_params) to skip the auto-assign callback
|
||||
# for that record. Used by the admin invite form (opt-out checkbox) and by
|
||||
# tests that want a clean slate.
|
||||
attr_accessor :skip_auto_assign
|
||||
|
||||
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
|
||||
|
||||
def admin?
|
||||
groups.any?(&:admin?)
|
||||
end
|
||||
|
||||
# TOTP methods
|
||||
def totp_enabled?
|
||||
@@ -222,6 +233,10 @@ class User < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def add_to_auto_assign_groups
|
||||
Group.auto_assign.each { |g| groups << g }
|
||||
end
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
|
||||
@@ -274,11 +274,11 @@
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<% if @allowed_groups.empty? %>
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
|
||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
No groups assigned - all active users can access this application.
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
No groups assigned — no one can access this application. Attach a group to grant access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,4 +299,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users with access -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Users with access (<%= @users_with_access.count %>)
|
||||
</h3>
|
||||
<% if @users_with_access.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @users_with_access.each do |user| %>
|
||||
<% via = user.groups & @application.allowed_groups %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<% via.each do |g| %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
<%= form.text_area :description, rows: 3, 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", placeholder: "Optional description of this group" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<div class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||
<% if @group.auto_assign? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
<% if @group.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @group.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -33,11 +33,36 @@
|
||||
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, 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 class="flex items-center">
|
||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
|
||||
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<% if user == Current.session.user %>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span>
|
||||
<div>
|
||||
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||
<% if @available_groups.any? %>
|
||||
<% @available_groups.each do |group| %>
|
||||
<div class="flex items-center">
|
||||
<%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
<% if group.admin? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% if group.auto_assign? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
|
||||
<% unless user.persisted? %>
|
||||
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
|
||||
<% if auto_names.any? %>
|
||||
<div class="mt-2 flex items-center">
|
||||
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
95
app/views/admin/users/show.html.erb
Normal file
95
app/views/admin/users/show.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<div class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
|
||||
<% if @user.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% case @user.status %>
|
||||
<% when "active" %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
||||
<% when "disabled" %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
|
||||
<% when "pending_invitation" %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @user.name.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Group memberships -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Group memberships (<%= @user.groups.count %>)
|
||||
</h3>
|
||||
<% if @user.groups.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @user.groups.order(:name).each do |group| %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||
<% if group.admin? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||
<% end %>
|
||||
<% if group.auto_assign? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accessible applications -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||
Accessible applications (<%= @accessible_applications.count %>)
|
||||
</h3>
|
||||
<% unless @user.active? %>
|
||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @accessible_applications.any? %>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<% @accessible_applications.each do |app| %>
|
||||
<% via = app.allowed_groups & @user.groups %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<% via.each do |g| %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user