Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2a2374f
Scope department name uniqueness to siblings
klaustopher Jun 22, 2026
56a85d6
Add ldap_managed? registration hook to Group
klaustopher Jun 22, 2026
7f77064
Lock LDAP-managed departments via contracts
klaustopher Jun 22, 2026
044c05f
Scaffold ldap_departments module
klaustopher Jun 22, 2026
7e3acaf
Add ldap_departments tables migration
klaustopher Jun 22, 2026
56dfa91
Add ldap_departments models
klaustopher Jun 22, 2026
4d4b284
Add ldap_departments sync services
klaustopher Jun 22, 2026
4f6197a
Add ldap_departments specs and locale
klaustopher Jun 22, 2026
1045420
Add ldap_departments admin UI
klaustopher Jun 22, 2026
c6c9510
Hide management actions for LDAP-managed departments
klaustopher Jun 22, 2026
78999d5
Add real-LDAP integration specs and dev server task
klaustopher Jun 22, 2026
5524b35
Test user deletion cleans up department memberships
klaustopher Jun 22, 2026
52f1677
Build users index changes concurrently
klaustopher Jun 22, 2026
58b5d71
Use full Primer blankslate on department sync index
klaustopher Jun 22, 2026
0cfeb12
Add spacing between note and content on sync index
klaustopher Jun 22, 2026
f6118cc
Add dev passwords and OU/department info to LDAP dev server
klaustopher Jun 22, 2026
4d72dca
Confirm sync setup in LDAP dev server output
klaustopher Jun 22, 2026
010de7f
Block adding departments under LDAP-managed parents
klaustopher Jun 22, 2026
875b53f
link the organization name to go to department root
klaustopher Jun 22, 2026
674fe31
Link to synchronization for departments
klaustopher Jun 22, 2026
d070151
Fix constant resolution on populated department sync pages
klaustopher Jun 22, 2026
0a0b8d0
Disable managed departments as parent options and managed empty-state…
klaustopher Jun 22, 2026
011af05
Primerize department sync listings with border box tables
klaustopher Jun 22, 2026
ca822c0
Primerize department sync listings with border box tables
klaustopher Jun 22, 2026
c5cef22
Show synced tree config in a side panel; primer heading
klaustopher Jun 22, 2026
4583bbc
Primerize the department sync tree form
klaustopher Jun 22, 2026
95f688d
Group department sync form fields into fieldsets
klaustopher Jun 22, 2026
3adb5be
fix yamllint
klaustopher Jun 23, 2026
c1f9a43
Reorder yaml file to make the linter happy
klaustopher Jun 23, 2026
090ac98
Only check ldap_managed? for organizational units
klaustopher Jun 23, 2026
9313fb3
Test ldap_managed? short-circuits for non-departments
klaustopher Jun 23, 2026
249332a
Drop redundant ladle dev dependency from gemspec
klaustopher Jun 23, 2026
bb6329e
Drop defined? guard for core ldap_departments module
klaustopher Jun 23, 2026
3298e0c
Use department-specific enterprise banner text
klaustopher Jun 23, 2026
89eba77
fix yamllint for the blankslate
klaustopher Jun 23, 2026
1ce70d8
Allow deleting a group that is the parent of another group and move t…
klaustopher Jun 23, 2026
708a12f
Use DangerDialog to give the admin more context of what is happening …
klaustopher Jun 23, 2026
4698f6f
Build entire group hierarchy name to display
klaustopher Jun 23, 2026
8e08907
Properly handle a user that is part of an LDAP managed department
klaustopher Jun 23, 2026
8d8a8cd
Add specs that we can delete stuff when the EE token expired
klaustopher Jun 23, 2026
422fc90
Move the sync button into the background
klaustopher Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ PATH
specs:
openproject-job_status (1.0.0)

PATH
remote: modules/ldap_departments
specs:
openproject-ldap_departments (1.0.0)

PATH
remote: modules/ldap_groups
specs:
Expand Down Expand Up @@ -1683,6 +1688,7 @@ DEPENDENCIES
openproject-github_integration!
openproject-gitlab_integration!
openproject-job_status!
openproject-ldap_departments!
openproject-ldap_groups!
openproject-meeting!
openproject-octicons (~> 19.35.0)!
Expand Down Expand Up @@ -1849,7 +1855,6 @@ CHECKSUMS
browser (6.2.0) sha256=281d5295788825c9396427c292c2d2be0a5c91875c93c390fde6e5d61a5ace2d
budgets (1.0.0)
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler (4.0.14) sha256=d09a0a965cf772266a7e49e83610be7c2f4e49e61134c42a56804bb383cc24b8
byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2
Expand Down Expand Up @@ -2065,6 +2070,7 @@ CHECKSUMS
openproject-github_integration (1.0.0)
openproject-gitlab_integration (3.0.0)
openproject-job_status (1.0.0)
openproject-ldap_departments (1.0.0)
openproject-ldap_groups (1.0.0)
openproject-meeting (1.0.0)
openproject-octicons (19.35.0) sha256=a5033550d0961b4a8cb0993512a899716d633e17c2b5147bc6a9ed74f3952b38
Expand Down
1 change: 1 addition & 0 deletions Gemfile.modules
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ group :opf_plugins do
gem 'openproject-github_integration', path: 'modules/github_integration'
gem 'openproject-gitlab_integration', path: 'modules/gitlab_integration'
gem 'openproject-ldap_groups', path: 'modules/ldap_groups'
gem 'openproject-ldap_departments', path: 'modules/ldap_departments'
gem 'openproject-recaptcha', path: 'modules/recaptcha'
gem 'openproject-job_status', path: 'modules/job_status'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,12 @@ def item_attributes(dept)
end

def disabled_ids
# Disallow the department itself, its current parent and descendants (circular), as well as
# any department managed by LDAP (the sync owns its sub-tree).
@disabled_ids ||= Set.new(
[@department.id, @department.parent_id].compact + descendant_ids(@department.id)
[@department.id, @department.parent_id].compact +
descendant_ids(@department.id) +
@departments.select(&:ldap_managed?).map(&:id)
)
end

Expand Down
18 changes: 11 additions & 7 deletions app/components/admin/departments/department_row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,17 @@ def call
end

row.with_column do
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(
icon: "kebab-horizontal",
scheme: :invisible,
"aria-label": I18n.t(:label_actions)
)
menu_items(menu)
if @department.ldap_managed?
render(Primer::Beta::Label.new(scheme: :accent)) { I18n.t(:label_managed_by_ldap) }
else
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(
icon: "kebab-horizontal",
scheme: :invisible,
"aria-label": I18n.t(:label_actions)
)
menu_items(menu)
end
end
end
end
Expand Down
23 changes: 20 additions & 3 deletions app/components/admin/departments/detail_blankslate_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,30 @@ module Departments
class DetailBlankslateComponent < ApplicationComponent
include OpPrimer::ComponentHelpers

def initialize(group: nil)
super()
@group = group
end

def call
render(Primer::Beta::Blankslate.new(border: false)) do |component|
component.with_visual_icon(icon: :people, size: :medium)
component.with_heading(tag: :h2) { t("departments.detail_blankslate.heading") }
component.with_description { t("departments.detail_blankslate.description") }
if managed?
component.with_visual_icon(icon: :lock, size: :medium)
component.with_heading(tag: :h2) { t("departments.detail_blankslate.managed_heading") }
component.with_description { t("departments.detail_blankslate.managed_description") }
else
component.with_visual_icon(icon: :people, size: :medium)
component.with_heading(tag: :h2) { t("departments.detail_blankslate.heading") }
component.with_description { t("departments.detail_blankslate.description") }
end
end
end

private

def managed?
@group&.ldap_managed?
end
end
end
end
8 changes: 6 additions & 2 deletions app/components/admin/departments/detail_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ See COPYRIGHT and LICENSE files for more details.
end
end

if group
if group&.ldap_managed?
header.with_column do
render(Primer::Beta::Label.new(scheme: :accent)) { I18n.t(:label_managed_by_ldap) }
end
elsif group
header.with_column do
render(
Primer::Beta::IconButton.new(
Expand All @@ -62,7 +66,7 @@ See COPYRIGHT and LICENSE files for more details.
end
elsif show_department_empty_state? && !add_user? && !add_subgroup?
box.with_row do
render(Admin::Departments::DetailBlankslateComponent.new)
render(Admin::Departments::DetailBlankslateComponent.new(group:))
end
else
child_groups.each do |child|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,31 @@ See COPYRIGHT and LICENSE files for more details.

++#%>

<%# helpers.content_controller "admin--departments-page" %>

<%=
component_wrapper(tag: "turbo-frame", class: "admin-groups-tree-page--wrapper", refresh: :morph, data: { turbo_action: :advance }) do
render(Primer::Alpha::Layout.new(stacking_breakpoint: :md, overflow: :hidden, h: :full, classes: "admin-groups-tree-page")) do |content|
content.with_main(overflow: :auto) do
flex_layout do |main|
main.with_row do
render(Primer::OpenProject::SubHeader.new) do |subheader|
subheader.with_action_menu(leading_icon: :plus, trailing_icon: :"triangle-down", label: I18n.t(:button_add), button_arguments: { scheme: :primary, "aria-label": I18n.t(:button_add) }) do |menu|
if active_group
# Managed departments are owned by the LDAP sync: no manual add of members or sub-departments.
unless active_group&.ldap_managed?
main.with_row do
render(Primer::OpenProject::SubHeader.new) do |subheader|
subheader.with_action_menu(leading_icon: :plus, trailing_icon: :"triangle-down", label: I18n.t(:button_add), button_arguments: { scheme: :primary, "aria-label": I18n.t(:button_add) }) do |menu|
if active_group
menu.with_item(
label: I18n.t("departments.add_user"),
tag: :a,
href: new_user_admin_department_path(active_group),
content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } }
)
end
menu.with_item(
label: I18n.t("departments.add_user"),
label: I18n.t("departments.add_department"),
tag: :a,
href: new_user_admin_department_path(active_group),
href: new_department_admin_departments_path(parent_id: active_group&.id),
content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } }
)
end
menu.with_item(
label: I18n.t("departments.add_department"),
tag: :a,
href: new_department_admin_departments_path(parent_id: active_group&.id),
content_arguments: { data: { turbo_frame: Admin::Departments::DetailComponent.wrapper_key } }
)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ See COPYRIGHT and LICENSE files for more details.
component_wrapper do
flex_layout(align_items: :center, justify_content: :space_between) do |container|
container.with_column do
render(Primer::Beta::Heading.new(tag: :h4)) { organization_name }
render(Primer::Beta::Link.new(href: admin_departments_path, underline: false, color: :default)) do
render(Primer::Beta::Heading.new(tag: :h4)) { organization_name }
end
end

container.with_column do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(Primer::OpenProject::PageHeader.new) do |header|
header.with_title { t(:label_departments) }
header.with_description do
link_translate(:label_departments_description_html, links: { ldap_docs_article: "#" })
link_translate(:label_departments_description_html, links: { ldap_docs_article: ldap_departments_synchronized_trees_path })
end
header.with_breadcrumbs(
[
Expand Down
40 changes: 21 additions & 19 deletions app/components/admin/departments/user_row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,28 @@ def call
render(Users::AvatarComponent.new(user: @user, size: "mini"))
end

row.with_column do
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(
icon: "kebab-horizontal",
scheme: :invisible,
"aria-label": I18n.t(:label_actions)
)
menu.with_item(
label: I18n.t(:button_remove),
scheme: :danger,
tag: :a,
href: remove_user_admin_department_path(@group, @user.id),
content_arguments: {
data: {
turbo_confirm: I18n.t(:text_are_you_sure),
turbo_method: :delete,
turbo_frame: "_top"
unless @group&.ldap_managed?
row.with_column do
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(
icon: "kebab-horizontal",
scheme: :invisible,
"aria-label": I18n.t(:label_actions)
)
menu.with_item(
label: I18n.t(:button_remove),
scheme: :danger,
tag: :a,
href: remove_user_admin_department_path(@group, @user.id),
content_arguments: {
data: {
turbo_confirm: I18n.t(:text_are_you_sure),
turbo_method: :delete,
turbo_frame: "_top"
}
}
}
)
)
end
end
end
end
Expand Down
17 changes: 17 additions & 0 deletions app/contracts/groups/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,26 @@ class BaseContract < ::ModelContract

validate :validate_unique_users
validate :validate_users_not_in_other_department
validate :validate_not_ldap_managed
validate :validate_parent_not_ldap_managed

private

# Departments synchronized from LDAP are read-only: their name, parent and members are owned by
# the sync. The sync itself bypasses this through Groups::SyncUpdateContract.
def validate_not_ldap_managed
errors.add(:base, :ldap_managed) if model.ldap_managed?
end

# A department managed by LDAP owns its sub-tree, so new or moved departments may not be nested
# underneath one. The sync bypasses this through its permissive contracts.
def validate_parent_not_ldap_managed
return if model.parent_id.blank?

parent = Group.find_by(id: model.parent_id)
errors.add(:parent_id, :parent_ldap_managed) if parent&.ldap_managed?
end

# Validating on the group_users since those are dealt with in the
# corresponding services.
def validate_unique_users
Expand Down
10 changes: 10 additions & 0 deletions app/contracts/groups/delete_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,15 @@
module Groups
class DeleteContract < ::DeleteContract
delete_permission(:admin)

validate :validate_not_ldap_managed

private

# Departments synchronized from LDAP cannot be deleted manually; they are removed only once
# their organizational unit disappears from LDAP and the sync drops the mapping.
def validate_not_ldap_managed
errors.add(:base, :ldap_managed) if model.ldap_managed?
end
end
end
42 changes: 42 additions & 0 deletions app/contracts/groups/sync_create_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Groups
# Used by the LDAP department synchronization to create managed departments, including nesting
# them under other managed departments. It therefore skips the read-only / managed-parent guards
# that block interactive admins in Groups::BaseContract.
class SyncCreateContract < CreateContract
private

def validate_not_ldap_managed; end

def validate_parent_not_ldap_managed; end
end
end
42 changes: 42 additions & 0 deletions app/contracts/groups/sync_update_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Groups
# Used by the LDAP department synchronization to update managed departments (name, parent and
# members). It is the only path allowed to mutate a group that reports `ldap_managed?`, so it
# skips the read-only lock enforced for interactive admins by Groups::BaseContract.
class SyncUpdateContract < UpdateContract
private

def validate_not_ldap_managed; end

def validate_parent_not_ldap_managed; end
end
end
6 changes: 5 additions & 1 deletion app/forms/groups/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ class Form < ApplicationForm
) do |list|
parent_candidates.each do |group|
prefix = "\u00A0\u00A0" * (group.hierarchy_depth || 0)
list.option(label: "#{prefix}#{group.name}", value: group.id, selected: model.parent_id == group.id)
# Departments managed by LDAP own their sub-tree and cannot be chosen as a manual parent.
list.option(label: "#{prefix}#{group.name}",
value: group.id,
selected: model.parent_id == group.id,
disabled: group.ldap_managed?)
end
end

Expand Down
Loading
Loading