commit e7dd7b0f1971d48cf3e434d372a9c7fd9f7703f7 Author: ioresponse Date: Tue Dec 23 00:21:35 2025 +0900 Initial commit: Redmine LDAP Config Plugin 🤖 Generated with Claude Code diff --git a/app/controllers/ldap_config_controller.rb b/app/controllers/ldap_config_controller.rb new file mode 100644 index 0000000..3802bf2 --- /dev/null +++ b/app/controllers/ldap_config_controller.rb @@ -0,0 +1,111 @@ +class LdapConfigController < ApplicationController + layout 'admin' + before_action :require_admin + before_action :find_ldap, only: [:edit, :update, :destroy] + + def index + @ldap_sources = AuthSourceLdap.all + @ldap = AuthSourceLdap.new + @presets = ldap_presets + end + + def edit + @presets = ldap_presets + end + + def create + @ldap = AuthSourceLdap.new(ldap_params) + if @ldap.save + flash[:notice] = l(:notice_successful_create) + redirect_to ldap_config_path + else + @ldap_sources = AuthSourceLdap.all + @presets = ldap_presets + render :index + end + end + + def update + if @ldap.update(ldap_params) + flash[:notice] = l(:notice_successful_update) + else + flash[:error] = @ldap.errors.full_messages.join(', ') + end + redirect_to ldap_config_path + end + + def destroy + @ldap.destroy + flash[:notice] = l(:notice_successful_delete) + redirect_to ldap_config_path + end + + def test_connection + @ldap = AuthSourceLdap.new(ldap_params) + begin + @ldap.test_connection + render json: { success: true, message: 'Connection successful!' } + rescue => e + render json: { success: false, message: e.message } + end + end + + private + + def find_ldap + @ldap = AuthSourceLdap.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def ldap_params + params.require(:auth_source_ldap).permit( + :name, :host, :port, :tls, :verify_peer, + :account, :account_password, :base_dn, :filter, + :onthefly_register, :attr_login, :attr_firstname, + :attr_lastname, :attr_mail, :timeout + ) + end + + def ldap_presets + { + 'freeipa' => { + name: 'FreeIPA', + port: 389, + tls: false, + base_dn: 'cn=users,cn=accounts,dc=example,dc=com', + filter: '(objectClass=person)', + attr_login: 'uid', + attr_firstname: 'givenName', + attr_lastname: 'sn', + attr_mail: 'mail' + }, + 'active_directory' => { + name: 'Active Directory', + port: 389, + tls: false, + base_dn: 'CN=Users,DC=example,DC=com', + filter: '(&(objectClass=user)(!(objectClass=computer)))', + attr_login: 'sAMAccountName', + attr_firstname: 'givenName', + attr_lastname: 'sn', + attr_mail: 'mail' + }, + 'openldap' => { + name: 'OpenLDAP', + port: 389, + tls: false, + base_dn: 'ou=users,dc=example,dc=com', + filter: '(objectClass=inetOrgPerson)', + attr_login: 'uid', + attr_firstname: 'givenName', + attr_lastname: 'sn', + attr_mail: 'mail' + } + } + end + + def ldap_config_path + { controller: 'ldap_config', action: 'index' } + end +end diff --git a/app/views/ldap_config/_ldap_form.html.erb b/app/views/ldap_config/_ldap_form.html.erb new file mode 100644 index 0000000..82eeb02 --- /dev/null +++ b/app/views/ldap_config/_ldap_form.html.erb @@ -0,0 +1,110 @@ +
+ Connection Settings + +

+ <%= f.label :name, 'Name' %> * + <%= f.text_field :name, size: 30, required: true %> +

+ +

+ <%= f.label :host, 'Host' %> * + <%= f.text_field :host, size: 30, required: true, placeholder: 'ldap.example.com or IP' %> +

+ +

+ <%= f.label :port, 'Port' %> * + <%= f.number_field :port, value: (@ldap.port || 389), min: 1, max: 65535, required: true %> +

+ +

+ <%= f.label :tls, 'LDAPS (TLS)' %> + <%= f.check_box :tls %> + (Use port 636 for LDAPS) +

+ +

+ <%= f.label :verify_peer, 'Verify SSL Certificate' %> + <%= f.check_box :verify_peer %> +

+ +

+ <%= f.label :timeout, 'Timeout (seconds)' %> + <%= f.number_field :timeout, value: (@ldap.timeout || 20), min: 1, max: 300 %> +

+
+ + + +
+ Bind Credentials + +

+ <%= f.label :account, 'Bind DN' %> + <%= f.text_field :account, size: 60, placeholder: 'uid=admin,cn=users,cn=accounts,dc=example,dc=com' %> + (Leave empty for anonymous bind) +

+ +

+ <%= f.label :account_password, 'Bind Password' %> + <%= f.password_field :account_password, size: 30, autocomplete: 'off' %> +

+
+ +
+ Search Settings + +

+ <%= f.label :base_dn, 'Base DN' %> * + <%= f.text_field :base_dn, size: 60, placeholder: 'cn=users,cn=accounts,dc=example,dc=com' %> +

+ +

+ <%= f.label :filter, 'LDAP Filter' %> + <%= f.text_field :filter, size: 60, placeholder: '(objectClass=person)' %> +

+ +

+ <%= f.label :onthefly_register, 'On-the-fly user creation' %> + <%= f.check_box :onthefly_register, checked: true %> + (Automatically create Redmine user on first login) +

+
+ +
+ Attribute Mapping + +

+ <%= f.label :attr_login, 'Login attribute' %> * + <%= f.text_field :attr_login, size: 30, placeholder: 'uid' %> + (uid for LDAP, sAMAccountName for AD) +

+ +

+ <%= f.label :attr_firstname, 'First name attribute' %> * + <%= f.text_field :attr_firstname, size: 30, placeholder: 'givenName' %> +

+ +

+ <%= f.label :attr_lastname, 'Last name attribute' %> * + <%= f.text_field :attr_lastname, size: 30, placeholder: 'sn' %> +

+ +

+ <%= f.label :attr_mail, 'Email attribute' %> * + <%= f.text_field :attr_mail, size: 30, placeholder: 'mail' %> +

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

Edit LDAP: <%= @ldap.name %>

+ +<%= javascript_tag do %> +var ldapPresets = <%= raw @presets.to_json %>; + +function applyPreset(selectElement) { + var preset = selectElement.value; + if (preset && ldapPresets[preset]) { + var config = ldapPresets[preset]; + document.getElementById('auth_source_ldap_name').value = config.name; + document.getElementById('auth_source_ldap_port').value = config.port; + document.getElementById('auth_source_ldap_tls').checked = config.tls; + document.getElementById('auth_source_ldap_base_dn').value = config.base_dn; + document.getElementById('auth_source_ldap_filter').value = config.filter; + document.getElementById('auth_source_ldap_attr_login').value = config.attr_login; + document.getElementById('auth_source_ldap_attr_firstname').value = config.attr_firstname; + document.getElementById('auth_source_ldap_attr_lastname').value = config.attr_lastname; + document.getElementById('auth_source_ldap_attr_mail').value = config.attr_mail; + } +} + +function testLdapConnection() { + var form = document.getElementById('ldap-form'); + var formData = new FormData(form); + var testBtn = document.getElementById('test-btn'); + var resultDiv = document.getElementById('test-result'); + + testBtn.disabled = true; + testBtn.value = 'Testing...'; + resultDiv.innerHTML = ''; + + fetch('<%= url_for(controller: 'ldap_config', action: 'test_connection') %>', { + method: 'POST', + body: formData, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + } + }) + .then(response => response.json()) + .then(data => { + testBtn.disabled = false; + testBtn.value = 'Test Connection'; + if (data.success) { + resultDiv.innerHTML = '✔ ' + data.message + ''; + } else { + resultDiv.innerHTML = '✘ ' + data.message + ''; + } + }) + .catch(error => { + testBtn.disabled = false; + testBtn.value = 'Test Connection'; + resultDiv.innerHTML = 'Error: ' + error + ''; + }); +} +<% end %> + +<%= form_for @ldap, url: ldap_config_update_path(@ldap), method: :patch, + html: { id: 'ldap-form', class: 'tabular' } do |f| %> + +<%= render partial: 'ldap_form', locals: { f: f } %> + +

+ <%= f.submit 'Save', class: 'button-positive' %> + + <%= link_to 'Cancel', { controller: 'ldap_config', action: 'index' }, class: 'button' %> + +

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

LDAP Configuration

+ +<%= javascript_tag do %> +var currentPreset = null; + +function domainToDC(domain) { + if (!domain) return ''; + return domain.split('.').map(function(part) { + return 'dc=' + part; + }).join(','); +} + +function updateFromDomain() { + var domain = document.getElementById('domain_input').value; + if (!currentPreset || !domain) return; + + var dc = domainToDC(domain); + var bindUser = document.getElementById('bind_user').value || 'admin'; + var baseDN, bindDN, filter, attrLogin; + + if (currentPreset === 'freeipa') { + baseDN = 'cn=users,cn=accounts,' + dc; + bindDN = 'uid=' + bindUser + ',cn=users,cn=accounts,' + dc; + filter = '(objectClass=person)'; + attrLogin = 'uid'; + } else if (currentPreset === 'active_directory') { + baseDN = 'CN=Users,' + dc.toUpperCase().replace(/dc=/g, 'DC='); + bindDN = bindUser + '@' + domain; + filter = '(&(objectClass=user)(!(objectClass=computer)))'; + attrLogin = 'sAMAccountName'; + } else if (currentPreset === 'openldap') { + baseDN = 'ou=users,' + dc; + bindDN = 'cn=' + bindUser + ',' + dc; + filter = '(objectClass=inetOrgPerson)'; + attrLogin = 'uid'; + } + + document.getElementById('auth_source_ldap_base_dn').value = baseDN; + document.getElementById('auth_source_ldap_account').value = bindDN; + document.getElementById('auth_source_ldap_filter').value = filter; + document.getElementById('auth_source_ldap_attr_login').value = attrLogin; + document.getElementById('auth_source_ldap_attr_firstname').value = 'givenName'; + document.getElementById('auth_source_ldap_attr_lastname').value = 'sn'; + document.getElementById('auth_source_ldap_attr_mail').value = 'mail'; + document.getElementById('auth_source_ldap_onthefly_register').checked = true; +} + +function setFieldsReadonly(readonly) { + var fields = ['auth_source_ldap_base_dn', 'auth_source_ldap_account', 'auth_source_ldap_filter', + 'auth_source_ldap_attr_login', 'auth_source_ldap_attr_firstname', + 'auth_source_ldap_attr_lastname', 'auth_source_ldap_attr_mail']; + fields.forEach(function(id) { + var el = document.getElementById(id); + if (el) { + el.readOnly = readonly; + el.style.backgroundColor = readonly ? '#f0f0f0' : ''; + } + }); +} + +function applyPresetMode(preset) { + currentPreset = preset; + var domainSection = document.getElementById('domain-section'); + + if (preset && (preset === 'freeipa' || preset === 'active_directory' || preset === 'openldap')) { + domainSection.style.display = 'block'; + var names = { 'freeipa': 'FreeIPA', 'active_directory': 'Active Directory', 'openldap': 'OpenLDAP' }; + document.getElementById('auth_source_ldap_name').value = names[preset]; + setFieldsReadonly(true); + document.getElementById('domain_input').value = ''; + document.getElementById('bind_user').value = 'admin'; + } else { + domainSection.style.display = 'none'; + setFieldsReadonly(false); + document.getElementById('auth_source_ldap_name').value = ''; + } +} + +function testLdapConnection() { + var form = document.getElementById('ldap-form'); + var formData = new FormData(form); + var testBtn = document.getElementById('test-btn'); + var resultDiv = document.getElementById('test-result'); + + testBtn.disabled = true; + testBtn.value = 'Testing...'; + resultDiv.innerHTML = ''; + + fetch('<%= url_for(controller: 'ldap_config', action: 'test_connection') %>', { + method: 'POST', + body: formData, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + } + }) + .then(function(response) { return response.json(); }) + .then(function(data) { + testBtn.disabled = false; + testBtn.value = 'Test Connection'; + if (data.success) { + resultDiv.innerHTML = 'OK: ' + data.message + ''; + } else { + resultDiv.innerHTML = 'FAIL: ' + data.message + ''; + } + }) + .catch(function(error) { + testBtn.disabled = false; + testBtn.value = 'Test Connection'; + resultDiv.innerHTML = 'Error: ' + error + ''; + }); +} + + +$(document).ready(function() { + $('#domain_input').on('keyup change', function() { + updateFromDomain(); + }); + $('#bind_user').on('keyup change', function() { + updateFromDomain(); + }); + $('#preset').on('change', function() { + applyPresetMode(this.value); + }); +}); +<% end %> + +<% if @ldap_sources.any? %> +

Existing LDAP Sources

+ + + + + + + + + + + + + + + <% @ldap_sources.each do |ldap| %> + + + + + + + + + + + <% end %> + +
NameHostPortTLSBase DNOn-the-flyUsersActions
<%= ldap.name %><%= ldap.host %><%= ldap.port %><%= ldap.tls ? 'Yes' : 'No' %><%= truncate(ldap.base_dn, length: 40) %><%= ldap.onthefly_register ? 'Yes' : 'No' %><%= ldap.users.count %> + <%= link_to 'Edit', ldap_config_edit_path(ldap), class: 'icon icon-edit' %> + <%= link_to 'Delete', ldap_config_delete_path(ldap), + method: :delete, + data: { confirm: 'Are you sure?' }, + class: 'icon icon-del' %> +
+
+<% end %> + +

Add New LDAP Source

+ +
+

+ + + (Select preset for simplified setup) +

+
+ +<%= form_for @ldap, url: { controller: 'ldap_config', action: 'create' }, + html: { id: 'ldap-form', class: 'tabular' } do |f| %> + +<%= render partial: 'ldap_form', locals: { f: f } %> + +

+ <%= f.submit 'Create', class: 'button-positive' %> + + +

+ +<% end %> diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..1a18436 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,8 @@ +Rails.application.routes.draw do + get 'ldap_config', to: 'ldap_config#index' + get 'ldap_config/:id/edit', to: 'ldap_config#edit', as: 'ldap_config_edit' + post 'ldap_config/create', to: 'ldap_config#create' + patch 'ldap_config/:id', to: 'ldap_config#update', as: 'ldap_config_update' + post 'ldap_config/test', to: 'ldap_config#test_connection' + delete 'ldap_config/:id', to: 'ldap_config#destroy', as: 'ldap_config_delete' +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..5fb2822 --- /dev/null +++ b/init.rb @@ -0,0 +1,15 @@ +Redmine::Plugin.register :ldap_config do + name 'LDAP Config' + author 'System Admin' + description 'Web-based LDAP configuration plugin for Redmine' + version '1.0.0' + url 'https://github.com/example/ldap_config' + author_url 'https://example.com' + + # Admin menu + menu :admin_menu, :ldap_config, + { controller: 'ldap_config', action: 'index' }, + caption: 'LDAP Config', + html: { class: 'icon icon-server-authentication' }, + after: :ldap_authentication +end