Initial commit: Redmine LDAP Config Plugin

🤖 Generated with Claude Code
This commit is contained in:
ioresponse 2025-12-23 00:21:35 +09:00
commit e7dd7b0f19
6 changed files with 505 additions and 0 deletions

View File

@ -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

View File

@ -0,0 +1,110 @@
<fieldset class="box tabular">
<legend>Connection Settings</legend>
<p>
<%= f.label :name, 'Name' %> <span class="required">*</span>
<%= f.text_field :name, size: 30, required: true %>
</p>
<p>
<%= f.label :host, 'Host' %> <span class="required">*</span>
<%= f.text_field :host, size: 30, required: true, placeholder: 'ldap.example.com or IP' %>
</p>
<p>
<%= f.label :port, 'Port' %> <span class="required">*</span>
<%= f.number_field :port, value: (@ldap.port || 389), min: 1, max: 65535, required: true %>
</p>
<p>
<%= f.label :tls, 'LDAPS (TLS)' %>
<%= f.check_box :tls %>
<em class="info">(Use port 636 for LDAPS)</em>
</p>
<p>
<%= f.label :verify_peer, 'Verify SSL Certificate' %>
<%= f.check_box :verify_peer %>
</p>
<p>
<%= f.label :timeout, 'Timeout (seconds)' %>
<%= f.number_field :timeout, value: (@ldap.timeout || 20), min: 1, max: 300 %>
</p>
</fieldset>
<fieldset class="box tabular" id="domain-section" style="display: none;">
<legend>Domain Settings (Auto-Configure)</legend>
<p>
<label for="domain_input">Domain</label> <span class="required">*</span>
<input type="text" id="domain_input" size="30" placeholder="finnq.com" />
<em class="info">(Enter domain to auto-configure LDAP settings)</em>
</p>
<p>
<label for="bind_user">Bind User</label>
<input type="text" id="bind_user" size="20" value="admin" />
<em class="info">(Default: admin)</em>
</p>
</fieldset>
<fieldset class="box tabular" id="bind-section">
<legend>Bind Credentials</legend>
<p>
<%= f.label :account, 'Bind DN' %>
<%= f.text_field :account, size: 60, placeholder: 'uid=admin,cn=users,cn=accounts,dc=example,dc=com' %>
<em class="info">(Leave empty for anonymous bind)</em>
</p>
<p>
<%= f.label :account_password, 'Bind Password' %>
<%= f.password_field :account_password, size: 30, autocomplete: 'off' %>
</p>
</fieldset>
<fieldset class="box tabular" id="search-section">
<legend>Search Settings</legend>
<p>
<%= f.label :base_dn, 'Base DN' %> <span class="required">*</span>
<%= f.text_field :base_dn, size: 60, placeholder: 'cn=users,cn=accounts,dc=example,dc=com' %>
</p>
<p>
<%= f.label :filter, 'LDAP Filter' %>
<%= f.text_field :filter, size: 60, placeholder: '(objectClass=person)' %>
</p>
<p>
<%= f.label :onthefly_register, 'On-the-fly user creation' %>
<%= f.check_box :onthefly_register, checked: true %>
<em class="info">(Automatically create Redmine user on first login)</em>
</p>
</fieldset>
<fieldset class="box tabular" id="attr-section">
<legend>Attribute Mapping</legend>
<p>
<%= f.label :attr_login, 'Login attribute' %> <span class="required">*</span>
<%= f.text_field :attr_login, size: 30, placeholder: 'uid' %>
<em class="info">(uid for LDAP, sAMAccountName for AD)</em>
</p>
<p>
<%= f.label :attr_firstname, 'First name attribute' %> <span class="required">*</span>
<%= f.text_field :attr_firstname, size: 30, placeholder: 'givenName' %>
</p>
<p>
<%= f.label :attr_lastname, 'Last name attribute' %> <span class="required">*</span>
<%= f.text_field :attr_lastname, size: 30, placeholder: 'sn' %>
</p>
<p>
<%= f.label :attr_mail, 'Email attribute' %> <span class="required">*</span>
<%= f.text_field :attr_mail, size: 30, placeholder: 'mail' %>
</p>
</fieldset>

View File

@ -0,0 +1,69 @@
<h2>Edit LDAP: <%= @ldap.name %></h2>
<%= 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 = '<span style="color: green; font-weight: bold;">&#10004; ' + data.message + '</span>';
} else {
resultDiv.innerHTML = '<span style="color: red; font-weight: bold;">&#10008; ' + data.message + '</span>';
}
})
.catch(error => {
testBtn.disabled = false;
testBtn.value = 'Test Connection';
resultDiv.innerHTML = '<span style="color: red;">Error: ' + error + '</span>';
});
}
<% 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 } %>
<p style="margin-top: 15px;">
<%= f.submit 'Save', class: 'button-positive' %>
<input type="button" id="test-btn" value="Test Connection" onclick="testLdapConnection()" class="button" />
<%= link_to 'Cancel', { controller: 'ldap_config', action: 'index' }, class: 'button' %>
<span id="test-result" style="margin-left: 10px;"></span>
</p>
<% end %>

View File

@ -0,0 +1,192 @@
<h2>LDAP Configuration</h2>
<%= 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 = '<span style="color: green; font-weight: bold;">OK: ' + data.message + '</span>';
} else {
resultDiv.innerHTML = '<span style="color: red; font-weight: bold;">FAIL: ' + data.message + '</span>';
}
})
.catch(function(error) {
testBtn.disabled = false;
testBtn.value = 'Test Connection';
resultDiv.innerHTML = '<span style="color: red;">Error: ' + error + '</span>';
});
}
$(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? %>
<h3>Existing LDAP Sources</h3>
<table class="list">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
<th>TLS</th>
<th>Base DN</th>
<th>On-the-fly</th>
<th>Users</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @ldap_sources.each do |ldap| %>
<tr>
<td><strong><%= ldap.name %></strong></td>
<td><%= ldap.host %></td>
<td><%= ldap.port %></td>
<td><%= ldap.tls ? 'Yes' : 'No' %></td>
<td><code style="font-size: 11px;"><%= truncate(ldap.base_dn, length: 40) %></code></td>
<td><%= ldap.onthefly_register ? 'Yes' : 'No' %></td>
<td><%= ldap.users.count %></td>
<td>
<%= 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' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<hr />
<% end %>
<h3>Add New LDAP Source</h3>
<div class="box">
<p>
<label for="preset">Quick Setup (Preset):</label>
<select id="preset">
<option value="">-- Manual Configuration --</option>
<option value="freeipa">FreeIPA</option>
<option value="active_directory">Active Directory</option>
<option value="openldap">OpenLDAP</option>
</select>
<em class="info">(Select preset for simplified setup)</em>
</p>
</div>
<%= form_for @ldap, url: { controller: 'ldap_config', action: 'create' },
html: { id: 'ldap-form', class: 'tabular' } do |f| %>
<%= render partial: 'ldap_form', locals: { f: f } %>
<p style="margin-top: 15px;">
<%= f.submit 'Create', class: 'button-positive' %>
<input type="button" id="test-btn" value="Test Connection" onclick="testLdapConnection()" class="button" />
<span id="test-result" style="margin-left: 10px;"></span>
</p>
<% end %>

8
config/routes.rb Normal file
View File

@ -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

15
init.rb Normal file
View File

@ -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