Initial commit: Redmine Role Groups Plugin

🤖 Generated with Claude Code
This commit is contained in:
ioresponse 2025-12-23 00:22:07 +09:00
commit 16263fdccb
12 changed files with 498 additions and 0 deletions

View File

@ -0,0 +1,225 @@
class RoleGroupsController < ApplicationController
layout 'admin'
before_action :require_admin
before_action :find_role_group, only: [:show, :edit, :update, :destroy, :add_member, :remove_member]
def index
@role_groups = RoleGroup.sorted.includes(:users)
end
def show
@members = @role_group.role_group_members.includes(:user).order('users.lastname, users.firstname')
end
def new
@role_group = RoleGroup.new
end
def create
@role_group = RoleGroup.new(role_group_params)
if @role_group.save
flash[:notice] = l(:notice_successful_create)
redirect_to '/role_groups'
else
render :new
end
end
def edit
end
def update
if @role_group.update(role_group_params)
flash[:notice] = l(:notice_successful_update)
redirect_to '/role_groups'
else
render :edit
end
end
def destroy
@role_group.destroy
flash[:notice] = l(:notice_successful_delete)
redirect_to '/role_groups'
end
def add_member
user = find_or_create_user_from_ldap(params[:user_id], params[:ldap_uid], params[:ldap_id])
if user
member = @role_group.role_group_members.find_or_initialize_by(user: user)
member.note = params[:note]
if member.save
flash[:notice] = "#{user.name} 추가됨"
else
flash[:error] = member.errors.full_messages.join(', ')
end
else
flash[:error] = '사용자를 찾을 수 없습니다'
end
redirect_to role_group_path(@role_group)
end
def remove_member
member = @role_group.role_group_members.find_by(user_id: params[:user_id])
if member
member.destroy
flash[:notice] = '멤버 제거됨'
end
redirect_to role_group_path(@role_group)
end
def search_ldap
query = params[:q].to_s.strip
if query.length < 2
render json: []
return
end
results = []
AuthSourceLdap.all.each do |ldap|
begin
ldap_users = search_ldap_users(ldap, query)
ldap_users.each do |entry|
uid = entry[ldap.attr_login]&.first
next unless uid
existing_user = User.find_by(login: uid)
results << {
uid: uid,
name: "#{entry[ldap.attr_firstname]&.first} #{entry[ldap.attr_lastname]&.first}".strip,
email: entry[ldap.attr_mail]&.first,
ldap_id: ldap.id,
exists: existing_user.present?,
user_id: existing_user&.id
}
end
rescue => e
Rails.logger.error "LDAP search error: #{e.message}"
end
end
render json: results.uniq { |r| r[:uid] }.first(20)
end
private
def find_role_group
@role_group = RoleGroup.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def role_group_params
params.require(:role_group).permit(:name, :description, :color, :position)
end
def search_ldap_users(ldap, query)
options = {
host: ldap.host,
port: ldap.port,
auth: {
method: :simple,
username: ldap.account,
password: ldap.account_password
}
}
if ldap.tls
if ldap.verify_peer
options[:encryption] = { method: :simple_tls }
else
options[:encryption] = { method: :simple_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_NONE } }
end
end
conn = Net::LDAP.new(options)
filter = Net::LDAP::Filter.begins(ldap.attr_login, query) |
Net::LDAP::Filter.begins(ldap.attr_firstname, query) |
Net::LDAP::Filter.begins(ldap.attr_lastname, query)
if ldap.filter.present?
base_filter = Net::LDAP::Filter.construct(ldap.filter)
filter = base_filter & filter
end
conn.search(
base: ldap.base_dn,
filter: filter,
attributes: [ldap.attr_login, ldap.attr_firstname, ldap.attr_lastname, ldap.attr_mail],
size: 20
) || []
end
def find_or_create_user_from_ldap(user_id, ldap_uid, ldap_id)
return User.find(user_id) if user_id.present?
return nil if ldap_uid.blank?
user = User.find_by(login: ldap_uid)
return user if user
ldap = AuthSourceLdap.find_by(id: ldap_id)
return nil unless ldap
user_info = get_ldap_user_info(ldap, ldap_uid)
return nil unless user_info
user = User.new(
login: ldap_uid,
firstname: user_info[:firstname] || ldap_uid,
lastname: user_info[:lastname] || '-',
mail: user_info[:mail] || "#{ldap_uid}@example.com",
auth_source_id: ldap.id,
status: User::STATUS_ACTIVE
)
user.random_password
user.save ? user : nil
end
def get_ldap_user_info(ldap, uid)
options = {
host: ldap.host,
port: ldap.port,
auth: {
method: :simple,
username: ldap.account,
password: ldap.account_password
}
}
if ldap.tls
if ldap.verify_peer
options[:encryption] = { method: :simple_tls }
else
options[:encryption] = { method: :simple_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_NONE } }
end
end
conn = Net::LDAP.new(options)
filter = Net::LDAP::Filter.eq(ldap.attr_login, uid)
if ldap.filter.present?
base_filter = Net::LDAP::Filter.construct(ldap.filter)
filter = base_filter & filter
end
result = conn.search(
base: ldap.base_dn,
filter: filter,
attributes: [ldap.attr_login, ldap.attr_firstname, ldap.attr_lastname, ldap.attr_mail],
size: 1
)&.first
return nil unless result
{
firstname: result[ldap.attr_firstname]&.first,
lastname: result[ldap.attr_lastname]&.first,
mail: result[ldap.attr_mail]&.first
}
end
end

12
app/models/role_group.rb Normal file
View File

@ -0,0 +1,12 @@
class RoleGroup < ActiveRecord::Base
has_many :role_group_members, dependent: :destroy
has_many :users, through: :role_group_members
validates :name, presence: true, uniqueness: true
scope :sorted, -> { order(:position, :name) }
def member_count
users.count
end
end

View File

@ -0,0 +1,7 @@
class RoleGroupMember < ActiveRecord::Base
belongs_to :role_group
belongs_to :user
validates :role_group_id, presence: true
validates :user_id, presence: true, uniqueness: { scope: :role_group_id }
end

View File

@ -0,0 +1,23 @@
<fieldset class="box tabular">
<legend>역할 그룹 정보</legend>
<p>
<%= f.label :name, '그룹명' %> <span class="required">*</span>
<%= f.text_field :name, size: 40, required: true, placeholder: 'DBA, 시스템, 방화벽 등' %>
</p>
<p>
<%= f.label :description, '설명' %>
<%= f.text_area :description, rows: 3, cols: 60, placeholder: '이 역할 그룹에 대한 설명' %>
</p>
<p>
<%= f.label :color, '색상' %>
<%= f.color_field :color, value: (@role_group.color || '#007bff') %>
</p>
<p>
<%= f.label :position, '순서' %>
<%= f.number_field :position, value: (@role_group.position || 0), min: 0 %>
</p>
</fieldset>

View File

@ -0,0 +1,9 @@
<h2>역할 그룹 수정: <%= @role_group.name %></h2>
<%= form_for @role_group, url: update_role_group_path(@role_group), method: :patch, html: { class: 'tabular' } do |f| %>
<%= render partial: 'form', locals: { f: f } %>
<p>
<%= f.submit '저장', class: 'button-positive' %>
<%= link_to '취소', '/role_groups', class: 'button' %>
</p>
<% end %>

View File

@ -0,0 +1,38 @@
<h2>역할 그룹 관리</h2>
<p>
<%= link_to '새 역할 그룹', new_role_group_path, class: 'icon icon-add' %>
</p>
<% if @role_groups.any? %>
<table class="list">
<thead>
<tr>
<th>역할 그룹</th>
<th>설명</th>
<th>멤버 수</th>
<th>작업</th>
</tr>
</thead>
<tbody>
<% @role_groups.each do |group| %>
<tr>
<td>
<span style="display: inline-block; width: 12px; height: 12px; background: <%= group.color %>; border-radius: 2px; margin-right: 8px;"></span>
<strong><%= link_to group.name, role_group_path(group) %></strong>
</td>
<td><%= truncate(group.description, length: 50) %></td>
<td><%= group.member_count %>명</td>
<td>
<%= link_to '관리', role_group_path(group), class: 'icon icon-user' %>
<%= link_to '수정', edit_role_group_path(group), class: 'icon icon-edit' %>
<%= link_to '삭제', destroy_role_group_path(group), method: :delete,
data: { confirm: '정말 삭제하시겠습니까?' }, class: 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">등록된 역할 그룹이 없습니다.</p>
<% end %>

View File

@ -0,0 +1,9 @@
<h2>새 역할 그룹</h2>
<%= form_for @role_group, url: '/role_groups/create', html: { class: 'tabular' } do |f| %>
<%= render partial: 'form', locals: { f: f } %>
<p>
<%= f.submit '생성', class: 'button-positive' %>
<%= link_to '취소', '/role_groups', class: 'button' %>
</p>
<% end %>

View File

@ -0,0 +1,125 @@
<h2>
<span style="display: inline-block; width: 16px; height: 16px; background: <%= @role_group.color %>; border-radius: 3px; margin-right: 10px; vertical-align: middle;"></span>
<%= @role_group.name %>
</h2>
<% if @role_group.description.present? %>
<p><%= @role_group.description %></p>
<% end %>
<hr />
<h3>멤버 (<%= @members.count %>명)</h3>
<% if @members.any? %>
<table class="list">
<thead>
<tr>
<th>이름</th>
<th>ID</th>
<th>이메일</th>
<th>비고</th>
<th>작업</th>
</tr>
</thead>
<tbody>
<% @members.each do |member| %>
<tr>
<td><%= link_to member.user.name, user_path(member.user) %></td>
<td><%= member.user.login %></td>
<td><%= member.user.mail %></td>
<td><%= member.note %></td>
<td>
<%= link_to '제거', remove_member_role_group_path(@role_group, user_id: member.user_id),
method: :delete, data: { confirm: '이 멤버를 제거하시겠습니까?' }, class: 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">멤버가 없습니다.</p>
<% end %>
<hr />
<h3>멤버 추가 (LDAP 검색)</h3>
<div class="box">
<p>
<label for="ldap_search">사용자 검색:</label>
<input type="text" id="ldap_search" size="30" placeholder="이름 또는 ID로 검색..." autocomplete="off" />
<span id="search_status" style="margin-left: 10px; color: #666;"></span>
</p>
<div id="search_results" style="margin-top: 10px;"></div>
</div>
<hr />
<p>
<%= link_to '목록으로', '/role_groups', class: 'icon icon-back' %>
<%= link_to '수정', edit_role_group_path(@role_group), class: 'icon icon-edit' %>
</p>
<%= javascript_tag do %>
var searchTimeout = null;
$('#ldap_search').on('keyup', function() {
var query = $(this).val();
if (searchTimeout) clearTimeout(searchTimeout);
if (query.length < 2) {
$('#search_results').html('');
$('#search_status').text('');
return;
}
$('#search_status').text('검색 중...');
searchTimeout = setTimeout(function() {
$.ajax({
url: '<%= search_ldap_role_groups_path %>',
data: { q: query },
dataType: 'json',
success: function(data) {
$('#search_status').text(data.length + '명 발견');
var html = '';
if (data.length === 0) {
html = '<p style="color: #999;">검색 결과가 없습니다.</p>';
} else {
html = '<table class="list" style="width: auto;"><thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>상태</th><th></th></tr></thead><tbody>';
data.forEach(function(user) {
html += '<tr>';
html += '<td>' + user.uid + '</td>';
html += '<td>' + user.name + '</td>';
html += '<td>' + (user.email || '-') + '</td>';
html += '<td>' + (user.exists ? '<span style="color: green;">등록됨</span>' : '<span style="color: blue;">LDAP</span>') + '</td>';
html += '<td>';
html += '<form action="<%= add_member_role_group_path(@role_group) %>" method="post" style="display:inline;">';
html += '<input type="hidden" name="authenticity_token" value="' + $('meta[name="csrf-token"]').attr('content') + '">';
if (user.exists) {
html += '<input type="hidden" name="user_id" value="' + user.user_id + '">';
} else {
html += '<input type="hidden" name="ldap_uid" value="' + user.uid + '">';
html += '<input type="hidden" name="ldap_id" value="' + user.ldap_id + '">';
}
html += '<input type="submit" value="추가" class="button-positive" style="padding: 2px 10px;">';
html += '</form>';
html += '</td>';
html += '</tr>';
});
html += '</tbody></table>';
}
$('#search_results').html(html);
},
error: function() {
$('#search_status').text('검색 오류');
$('#search_results').html('<p style="color: red;">검색 중 오류가 발생했습니다.</p>');
}
});
}, 300);
});
<% end %>

12
config/routes.rb Normal file
View File

@ -0,0 +1,12 @@
Rails.application.routes.draw do
get 'role_groups', to: 'role_groups#index'
get 'role_groups/new', to: 'role_groups#new', as: 'new_role_group'
get 'role_groups/search_ldap', to: 'role_groups#search_ldap', as: 'search_ldap_role_groups'
post 'role_groups/create', to: 'role_groups#create'
get 'role_groups/:id', to: 'role_groups#show', as: 'role_group'
get 'role_groups/:id/edit', to: 'role_groups#edit', as: 'edit_role_group'
patch 'role_groups/:id', to: 'role_groups#update', as: 'update_role_group'
delete 'role_groups/:id', to: 'role_groups#destroy', as: 'destroy_role_group'
post 'role_groups/:id/add_member', to: 'role_groups#add_member', as: 'add_member_role_group'
delete 'role_groups/:id/remove_member/:user_id', to: 'role_groups#remove_member', as: 'remove_member_role_group'
end

View File

@ -0,0 +1,13 @@
class CreateRoleGroups < ActiveRecord::Migration[6.1]
def change
create_table :role_groups do |t|
t.string :name, null: false
t.text :description
t.string :color, default: '#007bff'
t.integer :position, default: 0
t.timestamps
end
add_index :role_groups, :name, unique: true
end
end

View File

@ -0,0 +1,16 @@
class CreateRoleGroupMembers < ActiveRecord::Migration[6.1]
def change
create_table :role_group_members do |t|
t.bigint :role_group_id, null: false
t.integer :user_id, null: false
t.text :note
t.timestamps
end
add_index :role_group_members, :role_group_id
add_index :role_group_members, :user_id
add_index :role_group_members, [:role_group_id, :user_id], unique: true
add_foreign_key :role_group_members, :role_groups
add_foreign_key :role_group_members, :users
end
end

9
init.rb Normal file
View File

@ -0,0 +1,9 @@
Redmine::Plugin.register :role_groups do
name '역할 그룹 관리'
author 'Admin'
description 'Role-based grouping for users (DBA, System, Firewall, etc.)'
version '1.0.0'
menu :admin_menu, :role_groups, { controller: 'role_groups', action: 'index' },
caption: '역할 그룹', html: { class: 'icon icon-group' }
end