commit 16263fdccbb6068946d1d0f9299df64b010c2bf9 Author: ioresponse Date: Tue Dec 23 00:22:07 2025 +0900 Initial commit: Redmine Role Groups Plugin ๐Ÿค– Generated with Claude Code diff --git a/app/controllers/role_groups_controller.rb b/app/controllers/role_groups_controller.rb new file mode 100644 index 0000000..e7e3725 --- /dev/null +++ b/app/controllers/role_groups_controller.rb @@ -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 diff --git a/app/models/role_group.rb b/app/models/role_group.rb new file mode 100644 index 0000000..0ce2be1 --- /dev/null +++ b/app/models/role_group.rb @@ -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 diff --git a/app/models/role_group_member.rb b/app/models/role_group_member.rb new file mode 100644 index 0000000..b2c85bb --- /dev/null +++ b/app/models/role_group_member.rb @@ -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 diff --git a/app/views/role_groups/_form.html.erb b/app/views/role_groups/_form.html.erb new file mode 100644 index 0000000..f2f2bd5 --- /dev/null +++ b/app/views/role_groups/_form.html.erb @@ -0,0 +1,23 @@ +
+ ์—ญํ•  ๊ทธ๋ฃน ์ •๋ณด + +

+ <%= f.label :name, '๊ทธ๋ฃน๋ช…' %> * + <%= f.text_field :name, size: 40, required: true, placeholder: 'DBA, ์‹œ์Šคํ…œ, ๋ฐฉํ™”๋ฒฝ ๋“ฑ' %> +

+ +

+ <%= f.label :description, '์„ค๋ช…' %> + <%= f.text_area :description, rows: 3, cols: 60, placeholder: '์ด ์—ญํ•  ๊ทธ๋ฃน์— ๋Œ€ํ•œ ์„ค๋ช…' %> +

+ +

+ <%= f.label :color, '์ƒ‰์ƒ' %> + <%= f.color_field :color, value: (@role_group.color || '#007bff') %> +

+ +

+ <%= f.label :position, '์ˆœ์„œ' %> + <%= f.number_field :position, value: (@role_group.position || 0), min: 0 %> +

+
diff --git a/app/views/role_groups/edit.html.erb b/app/views/role_groups/edit.html.erb new file mode 100644 index 0000000..eeff5db --- /dev/null +++ b/app/views/role_groups/edit.html.erb @@ -0,0 +1,9 @@ +

์—ญํ•  ๊ทธ๋ฃน ์ˆ˜์ •: <%= @role_group.name %>

+ +<%= form_for @role_group, url: update_role_group_path(@role_group), method: :patch, html: { class: 'tabular' } do |f| %> + <%= render partial: 'form', locals: { f: f } %> +

+ <%= f.submit '์ €์žฅ', class: 'button-positive' %> + <%= link_to '์ทจ์†Œ', '/role_groups', class: 'button' %> +

+<% end %> diff --git a/app/views/role_groups/index.html.erb b/app/views/role_groups/index.html.erb new file mode 100644 index 0000000..2c8e7da --- /dev/null +++ b/app/views/role_groups/index.html.erb @@ -0,0 +1,38 @@ +

์—ญํ•  ๊ทธ๋ฃน ๊ด€๋ฆฌ

+ +

+ <%= link_to '์ƒˆ ์—ญํ•  ๊ทธ๋ฃน', new_role_group_path, class: 'icon icon-add' %> +

+ +<% if @role_groups.any? %> + + + + + + + + + + + <% @role_groups.each do |group| %> + + + + + + + <% end %> + +
์—ญํ•  ๊ทธ๋ฃน์„ค๋ช…๋ฉค๋ฒ„ ์ˆ˜์ž‘์—…
+ + <%= link_to group.name, role_group_path(group) %> + <%= truncate(group.description, length: 50) %><%= group.member_count %>๋ช… + <%= 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' %> +
+<% else %> +

๋“ฑ๋ก๋œ ์—ญํ•  ๊ทธ๋ฃน์ด ์—†์Šต๋‹ˆ๋‹ค.

+<% end %> diff --git a/app/views/role_groups/new.html.erb b/app/views/role_groups/new.html.erb new file mode 100644 index 0000000..da660ee --- /dev/null +++ b/app/views/role_groups/new.html.erb @@ -0,0 +1,9 @@ +

์ƒˆ ์—ญํ•  ๊ทธ๋ฃน

+ +<%= form_for @role_group, url: '/role_groups/create', html: { class: 'tabular' } do |f| %> + <%= render partial: 'form', locals: { f: f } %> +

+ <%= f.submit '์ƒ์„ฑ', class: 'button-positive' %> + <%= link_to '์ทจ์†Œ', '/role_groups', class: 'button' %> +

+<% end %> diff --git a/app/views/role_groups/show.html.erb b/app/views/role_groups/show.html.erb new file mode 100644 index 0000000..881b914 --- /dev/null +++ b/app/views/role_groups/show.html.erb @@ -0,0 +1,125 @@ +

+ + <%= @role_group.name %> +

+ +<% if @role_group.description.present? %> +

<%= @role_group.description %>

+<% end %> + +
+ +

๋ฉค๋ฒ„ (<%= @members.count %>๋ช…)

+ +<% if @members.any? %> + + + + + + + + + + + + <% @members.each do |member| %> + + + + + + + + <% end %> + +
์ด๋ฆ„ID์ด๋ฉ”์ผ๋น„๊ณ ์ž‘์—…
<%= link_to member.user.name, user_path(member.user) %><%= member.user.login %><%= member.user.mail %><%= member.note %> + <%= link_to '์ œ๊ฑฐ', remove_member_role_group_path(@role_group, user_id: member.user_id), + method: :delete, data: { confirm: '์ด ๋ฉค๋ฒ„๋ฅผ ์ œ๊ฑฐํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?' }, class: 'icon icon-del' %> +
+<% else %> +

๋ฉค๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+<% end %> + +
+ +

๋ฉค๋ฒ„ ์ถ”๊ฐ€ (LDAP ๊ฒ€์ƒ‰)

+ +
+

+ + + +

+
+
+ +
+ +

+ <%= link_to '๋ชฉ๋ก์œผ๋กœ', '/role_groups', class: 'icon icon-back' %> + <%= link_to '์ˆ˜์ •', edit_role_group_path(@role_group), class: 'icon icon-edit' %> +

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

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

'; + } else { + html = ''; + data.forEach(function(user) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
ID์ด๋ฆ„์ด๋ฉ”์ผ์ƒํƒœ
' + user.uid + '' + user.name + '' + (user.email || '-') + '' + (user.exists ? '๋“ฑ๋ก๋จ' : 'LDAP') + ''; + html += '
'; + html += ''; + if (user.exists) { + html += ''; + } else { + html += ''; + html += ''; + } + html += ''; + html += '
'; + html += '
'; + } + + $('#search_results').html(html); + }, + error: function() { + $('#search_status').text('๊ฒ€์ƒ‰ ์˜ค๋ฅ˜'); + $('#search_results').html('

๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.

'); + } + }); + }, 300); +}); +<% end %> diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..071c73b --- /dev/null +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/001_create_role_groups.rb b/db/migrate/001_create_role_groups.rb new file mode 100644 index 0000000..eba34b5 --- /dev/null +++ b/db/migrate/001_create_role_groups.rb @@ -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 diff --git a/db/migrate/002_create_role_group_members.rb b/db/migrate/002_create_role_group_members.rb new file mode 100644 index 0000000..6948ae8 --- /dev/null +++ b/db/migrate/002_create_role_group_members.rb @@ -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 diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..63cc2b2 --- /dev/null +++ b/init.rb @@ -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