diff --git a/ldap_groups_sync.routing.yml b/ldap_groups_sync.routing.yml
index ed5af7b..d59a06a 100644
--- a/ldap_groups_sync.routing.yml
+++ b/ldap_groups_sync.routing.yml
@@ -23,4 +23,4 @@ ldap_groups_sync.access_rule_form:
requirements:
_permission: 'administer ldap groups sync'
rule_index: 'new|\d+'
- group_type: 'departments|research_groups'
+ group_type: 'departments|research_groups|courses'
diff --git a/modules/ldap_courses_sync/config/optional/core.entity_form_display.group.course.default.yml b/modules/ldap_courses_sync/config/optional/core.entity_form_display.group.course.default.yml
new file mode 100644
index 0000000..2943e8c
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/core.entity_form_display.group.course.default.yml
@@ -0,0 +1,102 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.course.field_course_code
+ - field.field.group.course.field_course_coord
+ - field.field.group.course.field_course_coord_assoc
+ - field.field.group.course.field_course_mail
+ - field.field.group.course.field_course_phone
+ - field.field.group.course.field_course_department
+ - group.type.course
+ module:
+ - path
+id: group.course.default
+targetEntityType: group
+bundle: course
+mode: default
+content:
+ field_course_code:
+ type: string_textfield
+ weight: 122
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_course_mail:
+ type: email_default
+ weight: 125
+ region: content
+ settings:
+ placeholder: ''
+ size: 60
+ third_party_settings: { }
+ field_course_phone:
+ type: string_textfield
+ weight: 124
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_course_coord:
+ type: entity_reference_autocomplete
+ weight: 126
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_course_coord_assoc:
+ type: entity_reference_autocomplete
+ weight: 127
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_course_department:
+ type: entity_reference_autocomplete
+ weight: 25
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ label:
+ type: string_textfield
+ weight: -5
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ langcode:
+ type: language_select
+ weight: 2
+ region: content
+ settings:
+ include_locked: true
+ third_party_settings: { }
+ path:
+ type: path
+ weight: 30
+ region: content
+ settings: { }
+ third_party_settings: { }
+ status:
+ type: boolean_checkbox
+ weight: 120
+ region: content
+ settings:
+ display_label: true
+ third_party_settings: { }
+hidden:
+ uid: true
diff --git a/modules/ldap_courses_sync/config/optional/core.entity_view_display.group.course.default.yml b/modules/ldap_courses_sync/config/optional/core.entity_view_display.group.course.default.yml
new file mode 100644
index 0000000..06f2e5f
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/core.entity_view_display.group.course.default.yml
@@ -0,0 +1,81 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.course.field_course_code
+ - field.field.group.course.field_course_coord
+ - field.field.group.course.field_course_coord_assoc
+ - field.field.group.course.field_course_mail
+ - field.field.group.course.field_course_phone
+ - field.field.group.course.field_course_department
+ - group.type.course
+ module:
+ - options
+id: group.course.default
+targetEntityType: group
+bundle: course
+mode: default
+content:
+ field_course_code:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -3
+ region: content
+ field_course_mail:
+ type: basic_string
+ label: above
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+ field_course_phone:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -1
+ region: content
+ field_course_coord:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 1
+ region: content
+ field_course_coord_assoc:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 2
+ region: content
+ field_course_department:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 25
+ region: content
+ label:
+ type: string
+ label: hidden
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -5
+ region: content
+hidden:
+ changed: true
+ created: true
+ entity_print_view_epub: true
+ entity_print_view_pdf: true
+ entity_print_view_word_docx: true
+ langcode: true
+ uid: true
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_code.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_code.yml
new file mode 100644
index 0000000..e6ae29d
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_code.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_code
+ - group.type.course
+id: group.course.field_course_code
+field_name: field_course_code
+entity_type: group
+bundle: course
+label: 'Código'
+description: 'Código do curso (chave de sincronização LDAP)'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord.yml
new file mode 100644
index 0000000..ffc078e
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord.yml
@@ -0,0 +1,30 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_coord
+ - group.type.course
+ module:
+ - user
+id: group.course.field_course_coord
+field_name: field_course_coord
+entity_type: group
+bundle: course
+label: Coordenador
+description: 'Usuário responsável pela coordenação do curso'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:user'
+ handler_settings:
+ target_bundles: null
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ filter:
+ type: _none
+ include_anonymous: false
+field_type: entity_reference
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord_assoc.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord_assoc.yml
new file mode 100644
index 0000000..ffece98
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_coord_assoc.yml
@@ -0,0 +1,30 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_coord_assoc
+ - group.type.course
+ module:
+ - user
+id: group.course.field_course_coord_assoc
+field_name: field_course_coord_assoc
+entity_type: group
+bundle: course
+label: 'Coordenador Associado'
+description: 'Usuário responsável pela coordenação associada do curso'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:user'
+ handler_settings:
+ target_bundles: null
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ filter:
+ type: _none
+ include_anonymous: false
+field_type: entity_reference
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_department.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_department.yml
new file mode 100644
index 0000000..b048250
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_department.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_department
+ - group.type.department
+ - group.type.course
+id: group.course.field_course_department
+field_name: field_course_department
+entity_type: group
+bundle: course
+label: Departamento
+description: 'Departamento ao qual este curso está vinculado'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:group'
+ handler_settings:
+ target_bundles:
+ department: department
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ auto_create_bundle: ''
+field_type: entity_reference
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_mail.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_mail.yml
new file mode 100644
index 0000000..ac8f640
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_mail.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_mail
+ - group.type.course
+id: group.course.field_course_mail
+field_name: field_course_mail
+entity_type: group
+bundle: course
+label: 'E-mail'
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: email
diff --git a/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_phone.yml b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_phone.yml
new file mode 100644
index 0000000..c4496b1
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.group.course.field_course_phone.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_course_phone
+ - group.type.course
+ module:
+ - telephone
+id: group.course.field_course_phone
+field_name: field_course_phone
+entity_type: group
+bundle: course
+label: 'Telefone'
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: telephone
diff --git a/modules/ldap_courses_sync/config/optional/field.field.user.user.field_user_courses.yml b/modules/ldap_courses_sync/config/optional/field.field.user.user.field_user_courses.yml
new file mode 100644
index 0000000..b5b4a42
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.field.user.user.field_user_courses.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_courses
+ module:
+ - user
+id: user.user.field_user_courses
+field_name: field_user_courses
+entity_type: user
+bundle: user
+label: 'Cursos'
+description: 'Cursos do usuário sincronizados do LDAP'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:group'
+ handler_settings:
+ target_bundles:
+ course: course
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ auto_create_bundle: ''
+field_type: entity_reference
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_code.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_code.yml
new file mode 100644
index 0000000..0b6b011
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_code.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_course_code
+field_name: field_course_code
+entity_type: group
+type: string
+settings:
+ max_length: 255
+ case_sensitive: false
+ is_ascii: false
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord.yml
new file mode 100644
index 0000000..8fa267b
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_course_coord
+field_name: field_course_coord
+entity_type: group
+type: entity_reference
+settings:
+ target_type: user
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord_assoc.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord_assoc.yml
new file mode 100644
index 0000000..c47c145
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_coord_assoc.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_course_coord_assoc
+field_name: field_course_coord_assoc
+entity_type: group
+type: entity_reference
+settings:
+ target_type: user
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_department.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_department.yml
new file mode 100644
index 0000000..f4a08c4
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_department.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_course_department
+field_name: field_course_department
+entity_type: group
+type: entity_reference
+settings:
+ target_type: group
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_mail.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_mail.yml
new file mode 100644
index 0000000..528f037
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_mail.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_course_mail
+field_name: field_course_mail
+entity_type: group
+type: email
+settings: { }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_phone.yml b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_phone.yml
new file mode 100644
index 0000000..3e919a6
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.group.field_course_phone.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - telephone
+id: group.field_course_phone
+field_name: field_course_phone
+entity_type: group
+type: telephone
+settings: { }
+module: telephone
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/field.storage.user.field_user_courses.yml b/modules/ldap_courses_sync/config/optional/field.storage.user.field_user_courses.yml
new file mode 100644
index 0000000..e6698e1
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/field.storage.user.field_user_courses.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: user.field_user_courses
+field_name: field_user_courses
+entity_type: user
+type: entity_reference
+settings:
+ target_type: group
+module: core
+locked: false
+cardinality: -1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-admin.yml b/modules/ldap_courses_sync/config/optional/group.role.course-admin.yml
new file mode 100644
index 0000000..9aef4f6
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-admin.yml
@@ -0,0 +1,37 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+id: course-admin
+label: Admin
+weight: 100
+admin: true
+scope: individual
+global_role: null
+group_type: course
+permissions:
+ - administer members
+ - delete group
+ - edit group
+ - leave group
+ - view group
+ - view unpublished group
+ - access group_media overview
+ - access group_node overview
+ - create group_node:article entity
+ - create group_node:page entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-admin_in.yml b/modules/ldap_courses_sync/config/optional/group.role.course-admin_in.yml
new file mode 100644
index 0000000..ca4061c
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-admin_in.yml
@@ -0,0 +1,38 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+ - user.role.administrator
+id: course-admin_in
+label: Administrador
+weight: 102
+admin: true
+scope: insider
+global_role: administrator
+group_type: course
+permissions:
+ - administer members
+ - delete group
+ - edit group
+ - leave group
+ - view group
+ - view unpublished group
+ - access group_media overview
+ - access group_node overview
+ - create group_node:article entity
+ - create group_node:page entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-admin_out.yml b/modules/ldap_courses_sync/config/optional/group.role.course-admin_out.yml
new file mode 100644
index 0000000..a05037e
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-admin_out.yml
@@ -0,0 +1,38 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+ - user.role.administrator
+id: course-admin_out
+label: Administrador
+weight: 101
+admin: true
+scope: outsider
+global_role: administrator
+group_type: course
+permissions:
+ - administer members
+ - delete group
+ - edit group
+ - join group
+ - view group
+ - view unpublished group
+ - access group_media overview
+ - access group_node overview
+ - create group_node:article entity
+ - create group_node:page entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-anonymous.yml b/modules/ldap_courses_sync/config/optional/group.role.course-anonymous.yml
new file mode 100644
index 0000000..993201f
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-anonymous.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+ - user.role.anonymous
+id: course-anonymous
+label: 'Anônimo'
+weight: -102
+admin: false
+scope: outsider
+global_role: anonymous
+group_type: course
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-member.yml b/modules/ldap_courses_sync/config/optional/group.role.course-member.yml
new file mode 100644
index 0000000..3c02f38
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-member.yml
@@ -0,0 +1,27 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+ - user.role.authenticated
+id: course-member
+label: Member
+weight: -100
+admin: false
+scope: insider
+global_role: authenticated
+group_type: course
+permissions:
+ - view group
+ - access group_media overview
+ - access group_node overview
+ - create group_node:article entity
+ - create group_node:page entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.role.course-outsider.yml b/modules/ldap_courses_sync/config/optional/group.role.course-outsider.yml
new file mode 100644
index 0000000..3d1a659
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.role.course-outsider.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.course
+ - user.role.authenticated
+id: course-outsider
+label: Outsider
+weight: -101
+admin: false
+scope: outsider
+global_role: authenticated
+group_type: course
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/modules/ldap_courses_sync/config/optional/group.type.course.yml b/modules/ldap_courses_sync/config/optional/group.type.course.yml
new file mode 100644
index 0000000..7ae73e9
--- /dev/null
+++ b/modules/ldap_courses_sync/config/optional/group.type.course.yml
@@ -0,0 +1,10 @@
+langcode: pt-br
+status: true
+dependencies: { }
+id: course
+label: 'Curso'
+description: ''
+new_revision: true
+creator_membership: true
+creator_wizard: true
+creator_roles: { }
diff --git a/modules/ldap_courses_sync/css/role-mapping.css b/modules/ldap_courses_sync/css/role-mapping.css
new file mode 100644
index 0000000..f5f17b1
--- /dev/null
+++ b/modules/ldap_courses_sync/css/role-mapping.css
@@ -0,0 +1,73 @@
+/**
+ * Role mapping table styles
+ */
+
+/* Limita a largura da tabela e permite scroll horizontal se necessário */
+.role-mappings-table {
+ max-width: 100%;
+ table-layout: fixed;
+}
+
+/* Define larguras fixas para cada coluna */
+.role-mappings-table th:nth-child(1),
+.role-mappings-table td:nth-child(1) {
+ width: 20%;
+ min-width: 150px;
+}
+
+.role-mappings-table th:nth-child(2),
+.role-mappings-table td:nth-child(2) {
+ width: 15%;
+ min-width: 130px;
+}
+
+.role-mappings-table th:nth-child(3),
+.role-mappings-table td:nth-child(3) {
+ width: 25%;
+ min-width: 180px;
+}
+
+.role-mappings-table th:nth-child(4),
+.role-mappings-table td:nth-child(4) {
+ width: 30%;
+ min-width: 200px;
+}
+
+.role-mappings-table th:nth-child(5),
+.role-mappings-table td:nth-child(5) {
+ width: 10%;
+ min-width: 80px;
+ text-align: center;
+}
+
+/* Estilo dos selects para limitar largura e truncar texto */
+.role-mapping-field-select {
+ max-width: 100%;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Estilo para options dentro dos selects - mostra texto completo no dropdown */
+.role-mapping-field-select option {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+}
+
+/* Estilo do textarea */
+.role-mapping-values-textarea {
+ max-width: 100%;
+ width: 100%;
+ min-height: 50px;
+}
+
+/* Responsividade: em telas menores, permite scroll horizontal */
+@media (max-width: 1200px) {
+ .role-mappings-table {
+ display: block;
+ overflow-x: auto;
+ white-space: nowrap;
+ }
+}
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.info.yml b/modules/ldap_courses_sync/ldap_courses_sync.info.yml
new file mode 100644
index 0000000..8c35057
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.info.yml
@@ -0,0 +1,13 @@
+name: LDAP Courses Sync
+type: module
+description: 'Sincroniza cursos do servidor LDAP com grupos via cron'
+version: '1.0.0'
+core_version_requirement: ^11
+package: Custom
+dependencies:
+ - ldap_groups_sync:ldap_groups_sync
+ - drupal:options
+ - drupal:telephone
+ - group:group
+ - ldap:ldap_servers
+ - site_tools
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.install b/modules/ldap_courses_sync/ldap_courses_sync.install
new file mode 100644
index 0000000..694a66e
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.install
@@ -0,0 +1,23 @@
+addWarning(t('Please ensure your group type has the required fields: field_course_code, field_course_phone, field_course_mail, and field_course_department (for department reference).'));
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function ldap_courses_sync_uninstall() {
+ // Remove configurações
+ \Drupal::configFactory()->getEditable('ldap_courses_sync.settings')->delete();
+
+ \Drupal::messenger()->addStatus(t('LDAP Courses Sync module uninstalled.'));
+}
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.libraries.yml b/modules/ldap_courses_sync/ldap_courses_sync.libraries.yml
new file mode 100644
index 0000000..da24478
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.libraries.yml
@@ -0,0 +1,5 @@
+role_mapping_styles:
+ version: 1.x
+ css:
+ theme:
+ css/role-mapping.css: {}
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.links.menu.yml b/modules/ldap_courses_sync/ldap_courses_sync.links.menu.yml
new file mode 100644
index 0000000..456d3c8
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.links.menu.yml
@@ -0,0 +1 @@
+# Menu entry removed: the parent module ldap_groups_sync provides the unified menu entry.
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.links.task.yml b/modules/ldap_courses_sync/ldap_courses_sync.links.task.yml
new file mode 100644
index 0000000..384e279
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.links.task.yml
@@ -0,0 +1,5 @@
+ldap_courses_sync.tab.config:
+ title: 'Courses Sync'
+ route_name: ldap_courses_sync.config
+ base_route: ldap_groups_sync.config
+ weight: 30
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.module b/modules/ldap_courses_sync/ldap_courses_sync.module
new file mode 100644
index 0000000..8a4aa1f
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.module
@@ -0,0 +1,236 @@
+' . t('This module synchronizes courses from an LDAP server to groups through cron.') . '
';
+ }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function ldap_courses_sync_cron() {
+ // Check if module is properly configured
+ $config = \Drupal::config('ldap_courses_sync.settings');
+ $ldap_server_id = $config->get('ldap_server_id');
+ $ldap_query_id = $config->get('ldap_query_id');
+
+ // Validate LDAP server
+ try {
+ $entity_type_manager = \Drupal::entityTypeManager();
+ $server_storage = $entity_type_manager->getStorage('ldap_server');
+ $server = $server_storage->load($ldap_server_id);
+
+ if (!$server || !$server->get('status')) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: LDAP server "@server_id" not found or inactive. Configure at /admin/config/local-modules/ldap-courses-sync', [
+ '@server_id' => $ldap_server_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: error checking LDAP server: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Validate LDAP query
+ try {
+ if ($entity_type_manager->hasDefinition('ldap_query_entity')) {
+ $query_storage = $entity_type_manager->getStorage('ldap_query_entity');
+ $query = $query_storage->load($ldap_query_id);
+
+ if (!$query || !$query->get('status')) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: LDAP query "@query_id" not found or inactive. Configure at /admin/config/local-modules/ldap-courses-sync', [
+ '@query_id' => $ldap_query_id,
+ ]);
+ return;
+ }
+ }
+ else {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: ldap_query module not available.');
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: error checking LDAP query: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Validate group type
+ try {
+ $group_type_id = $config->get('group_type_id');
+ $group_type_storage = $entity_type_manager->getStorage('group_type');
+ $group_type = $group_type_storage->load($group_type_id);
+
+ if (!$group_type) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: group type "@type_id" not found. Configure at /admin/config/local-modules/ldap-courses-sync', [
+ '@type_id' => $group_type_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_courses_sync')->warning('Synchronization cancelled: error checking group type: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Get LDAP synchronization service
+ $ldap_sync = \Drupal::service('ldap_courses_sync.sync');
+
+ // Execute courses synchronization
+ try {
+ $ldap_sync->syncCourses();
+ \Drupal::logger('ldap_courses_sync')->info('Courses synchronization executed successfully.');
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_courses_sync')->error('Error in courses synchronization: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ }
+}
+
+/**
+ * Implements hook_entity_access().
+ *
+ * Evaluates access rules configured in the module settings for view, update
+ * and delete operations on existing entities.
+ */
+function ldap_courses_sync_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
+ // Skip group-related entities to avoid conflicts with the group module.
+ $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type'];
+ if (in_array($entity->getEntityTypeId(), $skip_types, TRUE)) {
+ return AccessResult::neutral();
+ }
+
+ return \Drupal::service('ldap_courses_sync.access_rules')
+ ->checkAccess($entity, $operation, $account);
+}
+
+/**
+ * Implements hook_entity_create_access().
+ *
+ * Evaluates access rules configured in the module settings for create
+ * operations on new entities.
+ */
+function ldap_courses_sync_entity_create_access(AccountInterface $account, array $context, $entity_bundle) {
+ $entity_type_id = $context['entity_type_id'] ?? '';
+
+ // Skip group-related entities.
+ $skip_types = ['group', 'group_content', 'group_relationship', 'group_role', 'group_type'];
+ if (in_array($entity_type_id, $skip_types, TRUE)) {
+ return AccessResult::neutral();
+ }
+
+ return \Drupal::service('ldap_courses_sync.access_rules')
+ ->checkCreateAccess($account, $entity_type_id, $entity_bundle ?? '');
+}
+
+/**
+ * Implements hook_entity_field_access().
+ *
+ * Denies edit access to fields managed by the LDAP sync in forms and APIs
+ * (REST, JSON:API). Programmatic saves are not affected.
+ *
+ * Protected user fields:
+ * - field_user_courses: multi-value reference to the courses the user belongs
+ * to, populated by our sync. Protecting this field prevents the validation
+ * error "This entity cannot be referenced" that occurs when the widget
+ * tries to validate the referenced group against the current user's access.
+ */
+function ldap_courses_sync_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
+ $protected_user_fields = [
+ 'field_user_courses',
+ ];
+
+ if (
+ $field_definition->getTargetEntityTypeId() === 'user' &&
+ in_array($field_definition->getName(), $protected_user_fields, TRUE) &&
+ $operation === 'edit'
+ ) {
+ // Sempre nega edição via UI/API, inclusive para o uid 1.
+ // Saves programáticos (ldap_user, drush) não passam por este hook.
+ return AccessResult::forbidden('This field is managed exclusively by LDAP synchronization.');
+ }
+ return AccessResult::neutral();
+}
+
+/**
+ * Implements hook_user_presave().
+ *
+ * Protects fields managed by the LDAP sync from unauthorized programmatic
+ * changes on existing users. Allows:
+ * - Saves during authorized LDAP sync (LdapCoursesSync::$syncing TRUE)
+ * - New user creation (initial provisioning via ldap_user)
+ * - Saves by users with the 'edit ldap managed user course fields' permission
+ */
+function ldap_courses_sync_user_presave(\Drupal\user\UserInterface $user) {
+ // Permite durante qualquer sync LDAP autorizado.
+ if (LdapCoursesSync::isSyncing()) {
+ return;
+ }
+
+ // Permite na criação inicial do usuário (primeiro login LDAP).
+ if ($user->isNew()) {
+ return;
+ }
+
+ // Permite se o usuário atual tem a permissão de bypass.
+ if (\Drupal::currentUser()->hasPermission('edit ldap managed user course fields')) {
+ return;
+ }
+
+ $original = $user->original;
+ if ($original === NULL) {
+ return;
+ }
+
+ // Fields managed by LDAP that cannot be changed externally.
+ $protected_fields = [
+ 'field_user_courses',
+ ];
+
+ foreach ($protected_fields as $field_name) {
+ if (!$user->hasField($field_name)) {
+ continue;
+ }
+
+ $original_value = $original->get($field_name)->getValue();
+ $new_value = $user->get($field_name)->getValue();
+
+ if ($original_value !== $new_value) {
+ $user->set($field_name, $original_value);
+ \Drupal::logger('ldap_courses_sync')->warning(
+ 'Unauthorized attempt to change @field on user @username was blocked. Use LDAP synchronization to update this field.',
+ [
+ '@field' => $field_name,
+ '@username' => $user->getAccountName(),
+ ]
+ );
+ }
+ }
+}
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.permissions.yml b/modules/ldap_courses_sync/ldap_courses_sync.permissions.yml
new file mode 100644
index 0000000..fbba76b
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.permissions.yml
@@ -0,0 +1,9 @@
+administer ldap courses sync:
+ title: 'Administer LDAP Courses Sync'
+ description: 'Configure the LDAP courses synchronization.'
+ restrict access: true
+
+edit ldap managed user course fields:
+ title: 'Edit LDAP-managed course fields on users'
+ description: 'Allows manually editing fields such as field_user_courses that are normally managed by LDAP synchronization. Use only in emergencies.'
+ restrict access: true
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.routing.yml b/modules/ldap_courses_sync/ldap_courses_sync.routing.yml
new file mode 100644
index 0000000..0592755
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.routing.yml
@@ -0,0 +1,7 @@
+ldap_courses_sync.config:
+ path: '/admin/config/local-modules/ldap-courses-sync'
+ defaults:
+ _form: '\Drupal\ldap_courses_sync\Form\LdapCoursesSyncConfigForm'
+ _title: 'LDAP Courses Sync'
+ requirements:
+ _permission: 'administer ldap courses sync'
diff --git a/modules/ldap_courses_sync/ldap_courses_sync.services.yml b/modules/ldap_courses_sync/ldap_courses_sync.services.yml
new file mode 100644
index 0000000..a9640ce
--- /dev/null
+++ b/modules/ldap_courses_sync/ldap_courses_sync.services.yml
@@ -0,0 +1,8 @@
+services:
+ ldap_courses_sync.sync:
+ class: Drupal\ldap_courses_sync\LdapCoursesSync
+ arguments: ['@entity_type.manager', '@config.factory', '@logger.factory', '@ldap.bridge']
+
+ ldap_courses_sync.access_rules:
+ class: Drupal\ldap_groups_sync\GroupAccessRulesService
+ arguments: ['@config.factory', '@entity_type.manager', 'ldap_courses_sync.settings']
diff --git a/modules/ldap_courses_sync/src/Form/LdapCoursesSyncConfigForm.php b/modules/ldap_courses_sync/src/Form/LdapCoursesSyncConfigForm.php
new file mode 100644
index 0000000..95d3842
--- /dev/null
+++ b/modules/ldap_courses_sync/src/Form/LdapCoursesSyncConfigForm.php
@@ -0,0 +1,1310 @@
+entityTypeManager = $entity_type_manager;
+ $this->ldapCoursesSync = $ldap_courses_sync;
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('ldap_courses_sync.sync'),
+ $container->get('messenger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['ldap_courses_sync.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'ldap_courses_sync_config_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $config = $this->config('ldap_courses_sync.settings');
+
+ // Group Type
+ $form['group_type'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Group Type'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $group_types = $this->getGroupTypes();
+
+ $form['group_type']['group_type_id'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Target Group Type'),
+ '#description' => $this->t('Select the group type where courses will be synchronized.
Required fields: field_course_code, field_course_phone, field_course_mail'),
+ '#options' => $group_types,
+ '#empty_option' => $this->t('- Select a group type -'),
+ '#default_value' => $config->get('group_type_id') ?: 'course',
+ '#required' => TRUE,
+ '#ajax' => [
+ 'callback' => '::updateGroupTypeActions',
+ 'wrapper' => 'group-type-wrapper',
+ 'effect' => 'fade',
+ 'disable-refocus' => TRUE,
+ ],
+ '#limit_validation_errors' => [
+ ['group_type_id'],
+ ],
+ ];
+
+ $form['group_type']['group_type_actions'] = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['form-actions', 'group-type-actions']],
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $course_exists = isset($group_types['course']);
+
+ if (empty($group_types) || !$course_exists) {
+ $form['group_type']['setup_notice'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('The default "course" group type is not installed.') .
+ '
',
+ '#weight' => -10,
+ ];
+
+ $form['group_type']['group_type_actions']['install_defaults'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Install Default Configuration'),
+ '#submit' => ['::installDefaultConfiguration'],
+ '#button_type' => 'primary',
+ '#limit_validation_errors' => [],
+ '#attributes' => ['class' => ['button', 'button--primary']],
+ ];
+
+ $form['group_type']['install_help'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('The button above will create:
+ - Group type "course" with all required fields
+ - User fields for course synchronization
+ - Default group roles and displays
+
Or you can
create a custom group type manually.', [
+ '@url' => '/admin/group/types/add',
+ ]) . '
',
+ '#weight' => 100,
+ ];
+ }
+
+ $form['group_type']['group_type_actions']['create_group_type'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('Create New Group Type') . ' ',
+ ];
+
+ $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'course';
+ if (!empty($selected_group_type)) {
+ $form['group_type']['group_type_actions']['edit_group_type'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('Manage Group Type') . ' ',
+ ];
+
+ $form['group_type']['group_type_actions']['edit_group_type_fields'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('Manage Fields') . '',
+ ];
+ }
+
+ // LDAP Server
+ $form['ldap_server'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('LDAP Server'),
+ '#collapsible' => FALSE,
+ ];
+
+ $ldap_servers = $this->getLdapServers();
+
+ $form['ldap_server']['ldap_server_id'] = [
+ '#type' => 'select',
+ '#title' => $this->t('LDAP Server'),
+ '#description' => $this->t('Select the LDAP server configured for courses synchronization.'),
+ '#options' => $ldap_servers,
+ '#empty_option' => $this->t('- Select a server -'),
+ '#default_value' => $config->get('ldap_server_id'),
+ '#required' => TRUE,
+ '#ajax' => [
+ 'callback' => '::updateLdapQueries',
+ 'wrapper' => 'ldap-queries-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [
+ ['group_type_id'],
+ ['ldap_server_id'],
+ ],
+ ];
+
+ if (empty($ldap_servers)) {
+ $form['ldap_server']['no_servers'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('No LDAP server found.
Configure an LDAP server first.', [
+ '@url' => '/admin/config/people/ldap/servers',
+ ]) . '
',
+ ];
+ }
+
+ // LDAP Query
+ $form['ldap_query'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('LDAP Query'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id');
+ $ldap_queries = $this->getLdapQueriesForServer($selected_server);
+
+ $form['ldap_query']['ldap_query_id'] = [
+ '#type' => 'select',
+ '#title' => $this->t('LDAP Query'),
+ '#description' => $this->t('Select the LDAP query that will be used to search for courses.'),
+ '#options' => $ldap_queries,
+ '#empty_option' => $this->t('- Select a query -'),
+ '#default_value' => $config->get('ldap_query_id'),
+ '#required' => TRUE,
+ '#ajax' => [
+ 'callback' => '::updateLdapQueries',
+ 'wrapper' => 'ldap-queries-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [
+ ['group_type_id'],
+ ['ldap_server_id'],
+ ['ldap_query_id'],
+ ],
+ ];
+
+ if (empty($ldap_queries) && !empty($selected_server)) {
+ $form['ldap_query']['no_queries'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('No LDAP query found for the selected server.
Create a new LDAP query.', [
+ '@url' => '/admin/config/people/ldap/query/add',
+ ]) . '
',
+ ];
+ }
+
+ if (!empty($selected_server)) {
+ $form['ldap_query']['query_actions'] = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['form-actions', 'query-actions']],
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $form['ldap_query']['query_actions']['create_query'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('Create New LDAP Query') . ' ',
+ ];
+
+ $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id');
+ if (!empty($selected_query)) {
+ $form['ldap_query']['query_actions']['edit_query'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('Edit Selected Query') . '',
+ ];
+ }
+ }
+
+ // Dynamic attribute mapping
+ $form['attribute_mapping'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Attribute Mapping'),
+ '#description' => $this->t('Configure how LDAP attributes are mapped to target entity fields.
Note: For "User Reference" mappings, the search attribute will be automatically obtained from the "Account name attribute" field configured in the selected LDAP server.'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'course';
+ $entity_fields = $this->getGroupTypeFields($selected_group_type);
+
+ $selected_server = $form_state->getValue('ldap_server_id') ?: $config->get('ldap_server_id');
+ $selected_query = $form_state->getValue('ldap_query_id') ?: $config->get('ldap_query_id');
+ $ldap_attributes = $this->getQueryAttributes($selected_server, $selected_query);
+
+ $mappings_reset = $form_state->get('mappings_reset');
+ $form_state_mappings = $form_state->getValue('mappings');
+
+ if ($mappings_reset && !empty($form_state_mappings)) {
+ $existing_mappings = $form_state_mappings;
+ }
+ elseif (!empty($form_state_mappings)) {
+ $existing_mappings = [];
+ $valid_fields = array_keys($entity_fields);
+
+ foreach ($form_state_mappings as $mapping) {
+ if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) {
+ $existing_mappings[] = $mapping;
+ }
+ }
+
+ if (empty($existing_mappings)) {
+ $existing_mappings = $this->getDefaultMappings();
+ }
+ }
+ else {
+ $config_mappings = $config->get('attribute_mappings');
+
+ if (!empty($config_mappings)) {
+ $valid_mappings = [];
+ $valid_fields = array_keys($entity_fields);
+
+ foreach ($config_mappings as $mapping) {
+ if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) {
+ $valid_mappings[] = $mapping;
+ }
+ }
+
+ $existing_mappings = !empty($valid_mappings) ? $valid_mappings : $this->getDefaultMappings();
+ }
+ else {
+ $existing_mappings = $this->getDefaultMappings();
+ }
+ }
+
+ $form['attribute_mapping']['mappings'] = [
+ '#type' => 'table',
+ '#header' => [
+ $this->t('Entity Field'),
+ $this->t('LDAP Attribute'),
+ $this->t('Mapping Type'),
+ $this->t('Remove'),
+ ],
+ '#empty' => $this->t('No mappings configured.'),
+ ];
+
+ $mapping_count = $form_state->get('mapping_count') ?: count($existing_mappings);
+ $form_state->set('mapping_count', $mapping_count);
+
+ for ($i = 0; $i < $mapping_count; $i++) {
+ $mapping = isset($existing_mappings[$i]) ? $existing_mappings[$i] : [
+ 'field' => '',
+ 'attribute' => '',
+ 'mapping_type' => 'simple',
+ ];
+
+ $field_default = isset($mapping['field']) ? $mapping['field'] : '';
+ if (!empty($field_default) && !isset($entity_fields[$field_default])) {
+ $field_default = '';
+ }
+
+ $attribute_default = isset($mapping['attribute']) ? $mapping['attribute'] : '';
+ if (!empty($attribute_default) && !isset($ldap_attributes[$attribute_default])) {
+ $attribute_default = '';
+ }
+
+ $field_element = [
+ '#type' => 'select',
+ '#options' => $entity_fields,
+ '#empty_option' => $this->t('- Select a field -'),
+ '#default_value' => $field_default,
+ '#required' => FALSE,
+ '#attributes' => [
+ 'style' => 'max-width: 200px; width: auto;',
+ ],
+ ];
+
+ if ($mappings_reset && !empty($field_default)) {
+ $field_element['#value'] = $field_default;
+ }
+
+ $form['attribute_mapping']['mappings'][$i]['field'] = $field_element;
+
+ $form['attribute_mapping']['mappings'][$i]['attribute'] = [
+ '#type' => 'select',
+ '#options' => $ldap_attributes,
+ '#empty_option' => $this->t('- Select an attribute -'),
+ '#default_value' => $attribute_default,
+ '#required' => FALSE,
+ '#attributes' => [
+ 'style' => 'max-width: 180px; width: auto;',
+ ],
+ ];
+
+ $form['attribute_mapping']['mappings'][$i]['mapping_type'] = [
+ '#type' => 'select',
+ '#options' => [
+ 'simple' => $this->t('Simple (text)'),
+ 'user_reference' => $this->t('User Reference (DN → User)'),
+ 'department_reference' => $this->t('Department Reference (code → Department)'),
+ ],
+ '#default_value' => $mapping['mapping_type'] ?? 'simple',
+ '#required' => FALSE,
+ '#attributes' => [
+ 'style' => 'max-width: 150px; width: auto;',
+ ],
+ ];
+
+ $form['attribute_mapping']['mappings'][$i]['remove'] = [
+ '#type' => 'checkbox',
+ '#default_value' => FALSE,
+ ];
+ }
+
+ if ($mappings_reset) {
+ $form_state->set('mappings_reset', FALSE);
+ }
+
+ $form['attribute_mapping']['actions'] = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['form-actions']],
+ ];
+
+ $form['attribute_mapping']['actions']['add_mapping'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Add Mapping'),
+ '#submit' => ['::addMapping'],
+ '#ajax' => [
+ 'callback' => '::updateAttributeMappings',
+ 'wrapper' => 'attribute-mapping-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [],
+ ];
+
+ $form['attribute_mapping']['actions']['remove_selected'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Remove Selected'),
+ '#submit' => ['::removeSelectedMappings'],
+ '#ajax' => [
+ 'callback' => '::updateAttributeMappings',
+ 'wrapper' => 'attribute-mapping-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [],
+ ];
+
+ // Member synchronization from LDAP attribute
+ $form['member_sync'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Member Synchronization'),
+ ];
+
+ $form['member_sync']['member_sync_enabled'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable member synchronization from LDAP attribute'),
+ '#description' => $this->t('When enabled, group memberships are managed based on the LDAP member attribute of each group entry. Members are added or removed to match the LDAP list.'),
+ '#default_value' => $config->get('member_sync_enabled') ?? TRUE,
+ ];
+
+ $form['member_sync']['member_attribute'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Member LDAP attribute'),
+ '#description' => $this->t('Name of the LDAP attribute on each group entry that lists its members (as DNs or UIDs). Common values: member, uniqueMember, memberUid. The attribute must be included in the LDAP query.'),
+ '#default_value' => $config->get('member_attribute') ?: 'member',
+ '#size' => 40,
+ ];
+
+ // Role Mapping
+ $form['role_mapping'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Group Role Mapping'),
+ '#description' => $this->t('Configure how user attributes map to group roles. Each role can have its own mapping criteria.'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $form['role_mapping']['role_mapping_enabled'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable role mapping'),
+ '#description' => $this->t('When enabled, users will be assigned group roles based on the mappings below.'),
+ '#default_value' => $config->get('role_mapping_enabled') ?: FALSE,
+ '#ajax' => [
+ 'callback' => '::updateRoleMappingFields',
+ 'wrapper' => 'role-mapping-fields-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [
+ ['group_type_id'],
+ ['ldap_server_id'],
+ ['ldap_query_id'],
+ ['role_mapping_enabled'],
+ ],
+ ];
+
+ $form['role_mapping']['role_mapping_fields'] = [
+ '#type' => 'container',
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $role_mapping_enabled_value = $form_state->getValue('role_mapping_enabled');
+ if ($role_mapping_enabled_value !== NULL) {
+ $role_mapping_enabled = (bool) $role_mapping_enabled_value;
+ }
+ else {
+ $role_mapping_enabled = $config->get('role_mapping_enabled') ?: FALSE;
+ }
+
+ if ($role_mapping_enabled) {
+ $group_roles = $this->getGroupRoles($selected_group_type);
+ $role_ldap_attributes = $ldap_attributes ?? [];
+ $user_fields = $this->getUserFields();
+
+ $role_form_state_mappings = $form_state->getValue('role_mappings');
+ if (!empty($role_form_state_mappings)) {
+ $existing_role_mappings = $role_form_state_mappings;
+ }
+ else {
+ $config_role_mappings = $config->get('role_mappings');
+ $existing_role_mappings = $config_role_mappings ?: [];
+ }
+
+ if (empty($existing_role_mappings)) {
+ $existing_role_mappings = [];
+ }
+
+ $form['role_mapping']['role_mapping_fields']['role_mappings'] = [
+ '#type' => 'table',
+ '#header' => [
+ $this->t('Group Role'),
+ $this->t('Source'),
+ $this->t('User Field / LDAP Attribute'),
+ $this->t('Group Field / Values'),
+ $this->t('Remove'),
+ ],
+ '#empty' => $this->t('No role mappings configured. Users will be assigned the default role.'),
+ '#attributes' => ['class' => ['role-mappings-table']],
+ '#attached' => [
+ 'library' => ['ldap_courses_sync/role_mapping_styles'],
+ ],
+ ];
+
+ $role_mapping_count = $form_state->get('role_mapping_count');
+ if ($role_mapping_count === NULL) {
+ $role_mapping_count = count($existing_role_mappings);
+ }
+ $form_state->set('role_mapping_count', $role_mapping_count);
+
+ $group_fields = $this->getGroupTypeFields($selected_group_type);
+
+ for ($i = 0; $i < $role_mapping_count; $i++) {
+ $mapping = isset($existing_role_mappings[$i]) ? $existing_role_mappings[$i] : [
+ 'group_role' => '',
+ 'source' => 'user_field',
+ 'source_field' => '',
+ 'group_field' => '',
+ 'values' => '',
+ ];
+
+ $current_source = $form_state->getValue(['role_mappings', $i, 'source']) ?? $mapping['source'] ?? 'user_field';
+
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['group_role'] = [
+ '#type' => 'select',
+ '#options' => $group_roles,
+ '#empty_option' => $this->t('- Select a role -'),
+ '#default_value' => $mapping['group_role'],
+ '#required' => FALSE,
+ ];
+
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source'] = [
+ '#type' => 'select',
+ '#options' => [
+ 'user_field' => $this->t('User Field'),
+ 'ldap_attribute' => $this->t('LDAP Attribute'),
+ 'group_field_match' => $this->t('Group Field Match'),
+ ],
+ '#default_value' => $current_source,
+ '#required' => FALSE,
+ '#ajax' => [
+ 'callback' => '::updateRoleMappingFields',
+ 'wrapper' => 'role-mapping-fields-wrapper',
+ 'effect' => 'fade',
+ ],
+ ];
+
+ if ($current_source === 'group_field_match') {
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [
+ '#type' => 'select',
+ '#options' => $user_fields,
+ '#empty_option' => $this->t('- Select user field -'),
+ '#default_value' => $mapping['source_field'],
+ '#required' => FALSE,
+ '#attributes' => ['class' => ['role-mapping-field-select']],
+ ];
+ }
+ else {
+ if ($current_source === 'ldap_attribute') {
+ $source_options = $role_ldap_attributes;
+ }
+ else {
+ $source_options = $user_fields;
+ }
+
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['source_field'] = [
+ '#type' => 'select',
+ '#options' => $source_options,
+ '#empty_option' => $this->t('- Select attribute/field -'),
+ '#default_value' => $mapping['source_field'],
+ '#required' => FALSE,
+ '#attributes' => ['class' => ['role-mapping-field-select']],
+ ];
+ }
+
+ if ($current_source === 'group_field_match') {
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [
+ '#type' => 'select',
+ '#options' => $group_fields,
+ '#empty_option' => $this->t('- Select group field -'),
+ '#default_value' => $mapping['group_field'] ?? '',
+ '#required' => FALSE,
+ '#attributes' => ['class' => ['role-mapping-field-select']],
+ ];
+ }
+ else {
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['values'] = [
+ '#type' => 'textarea',
+ '#default_value' => is_array($mapping['values']) ? implode(', ', $mapping['values']) : $mapping['values'],
+ '#placeholder' => $this->t('e.g., Director, Manager'),
+ '#rows' => 2,
+ '#resizable' => 'vertical',
+ '#attributes' => ['class' => ['role-mapping-values-textarea']],
+ ];
+ }
+
+ $form['role_mapping']['role_mapping_fields']['role_mappings'][$i]['remove'] = [
+ '#type' => 'checkbox',
+ '#default_value' => FALSE,
+ ];
+ }
+
+ $form['role_mapping']['role_mapping_fields']['actions'] = [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['form-actions']],
+ ];
+
+ $form['role_mapping']['role_mapping_fields']['actions']['add_role_mapping'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Add Role Mapping'),
+ '#submit' => ['::addRoleMapping'],
+ '#ajax' => [
+ 'callback' => '::updateRoleMappingFields',
+ 'wrapper' => 'role-mapping-fields-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [],
+ ];
+
+ $form['role_mapping']['role_mapping_fields']['actions']['remove_selected_roles'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Remove Selected'),
+ '#submit' => ['::removeSelectedRoleMappings'],
+ '#ajax' => [
+ 'callback' => '::updateRoleMappingFields',
+ 'wrapper' => 'role-mapping-fields-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [],
+ ];
+ }
+
+ // Actions
+ $form['actions'] = [
+ '#type' => 'actions',
+ ];
+
+ $form['actions']['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Save Configuration'),
+ '#button_type' => 'primary',
+ ];
+
+ $form['actions']['test_connection'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Test Connection'),
+ '#submit' => ['::testConnection'],
+ '#limit_validation_errors' => [['ldap_server_id']],
+ ];
+
+ $form['actions']['sync_courses'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Synchronize Courses'),
+ '#submit' => ['::syncCourses'],
+ '#button_type' => 'danger',
+ ];
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * Gets list of available LDAP servers.
+ */
+ protected function getLdapServers() {
+ $servers = [];
+
+ try {
+ $server_storage = $this->entityTypeManager->getStorage('ldap_server');
+ $server_entities = $server_storage->loadByProperties(['status' => TRUE]);
+
+ foreach ($server_entities as $server) {
+ $servers[$server->id()] = $server->label() . ' (' . $server->get('address') . ')';
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading LDAP servers: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $servers;
+ }
+
+ /**
+ * Gets attributes from the selected LDAP query.
+ */
+ protected function getQueryAttributes($server_id, $query_id) {
+ $attributes = [];
+
+ if (empty($query_id) || !$this->entityTypeManager->hasDefinition('ldap_query_entity')) {
+ return [
+ 'cn' => 'cn',
+ 'description' => 'description',
+ 'mail' => 'mail',
+ 'telephoneNumber' => 'telephoneNumber',
+ ];
+ }
+
+ try {
+ $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity');
+ $query_entity = $query_storage->load($query_id);
+
+ if ($query_entity) {
+ $processed_attributes = $query_entity->getProcessedAttributes();
+
+ if (!empty($processed_attributes)) {
+ foreach ($processed_attributes as $attribute) {
+ $attributes[$attribute] = $attribute;
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading query attributes: @message', ['@message' => $e->getMessage()]));
+ }
+
+ if (empty($attributes)) {
+ $attributes = [
+ 'cn' => 'cn',
+ 'description' => 'description',
+ 'mail' => 'mail',
+ 'telephoneNumber' => 'telephoneNumber',
+ ];
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Returns default mappings for courses.
+ */
+ protected function getDefaultMappings() {
+ return [
+ ['field' => 'label', 'attribute' => 'description', 'mapping_type' => 'simple'],
+ ['field' => 'field_course_code', 'attribute' => 'cn', 'mapping_type' => 'simple'],
+ ['field' => 'field_course_mail', 'attribute' => 'mail', 'mapping_type' => 'simple'],
+ ['field' => 'field_course_phone', 'attribute' => 'telephoneNumber', 'mapping_type' => 'simple'],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ $mapping_values = $form_state->getValue('mappings');
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $index => $mapping_data) {
+ if (!empty($mapping_data['remove'])) {
+ continue;
+ }
+
+ $has_field = !empty($mapping_data['field']);
+ $has_attribute = !empty($mapping_data['attribute']);
+
+ if ($has_field && !$has_attribute) {
+ $form_state->setErrorByName("mappings][$index][attribute",
+ $this->t('LDAP attribute is required when field is selected.'));
+ }
+ elseif (!$has_field && $has_attribute) {
+ $form_state->setErrorByName("mappings][$index][field",
+ $this->t('Vocabulary field is required when attribute is selected.'));
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ $config = $this->config('ldap_courses_sync.settings');
+
+ $config->set('ldap_server_id', $form_state->getValue('ldap_server_id'))
+ ->set('ldap_query_id', $form_state->getValue('ldap_query_id'))
+ ->set('group_type_id', $form_state->getValue('group_type_id'))
+ ->set('member_sync_enabled', $form_state->getValue('member_sync_enabled'))
+ ->set('member_attribute', $form_state->getValue('member_attribute'));
+
+ $mappings = [];
+ $mapping_values = $form_state->getValue('mappings');
+
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $mapping_data) {
+ if (!empty($mapping_data['remove'])) {
+ continue;
+ }
+
+ if (empty($mapping_data['field']) && empty($mapping_data['attribute'])) {
+ continue;
+ }
+
+ $mappings[] = [
+ 'field' => $mapping_data['field'],
+ 'attribute' => $mapping_data['attribute'],
+ 'mapping_type' => $mapping_data['mapping_type'] ?? 'simple',
+ ];
+ }
+ }
+
+ $config->set('attribute_mappings', $mappings);
+
+ $role_mappings = [];
+ $role_mapping_values = $form_state->getValue('role_mappings');
+
+ if (!empty($role_mapping_values)) {
+ foreach ($role_mapping_values as $role_mapping_data) {
+ if (!empty($role_mapping_data['remove'])) {
+ continue;
+ }
+
+ if (empty($role_mapping_data['group_role']) && empty($role_mapping_data['source_field'])) {
+ continue;
+ }
+
+ $source = $role_mapping_data['source'];
+
+ if ($source === 'group_field_match') {
+ $role_mapping = [
+ 'group_role' => $role_mapping_data['group_role'],
+ 'source' => $source,
+ 'source_field' => $role_mapping_data['source_field'],
+ 'group_field' => $role_mapping_data['values'],
+ 'values' => [],
+ ];
+ }
+ else {
+ $values = $role_mapping_data['values'];
+ if (is_string($values)) {
+ $values = array_map('trim', explode(',', $values));
+ $values = array_filter($values);
+ }
+
+ $role_mapping = [
+ 'group_role' => $role_mapping_data['group_role'],
+ 'source' => $source,
+ 'source_field' => $role_mapping_data['source_field'],
+ 'group_field' => '',
+ 'values' => $values,
+ ];
+ }
+
+ $role_mappings[] = $role_mapping;
+ }
+ }
+
+ $config->set('role_mapping_enabled', $form_state->getValue('role_mapping_enabled'))
+ ->set('role_mappings', $role_mappings)
+ ->save();
+
+ $this->messenger()->addStatus($this->t('Configuration saved successfully.'));
+ }
+
+ /**
+ * Submit handler to test LDAP connection.
+ */
+ public function testConnection(array &$form, FormStateInterface $form_state) {
+ $server_id = $form_state->getValue('ldap_server_id');
+
+ if (empty($server_id)) {
+ $this->messenger->addError($this->t('Please select an LDAP server to test.'));
+ return;
+ }
+
+ try {
+ $server_storage = $this->entityTypeManager->getStorage('ldap_server');
+ $server = $server_storage->load($server_id);
+
+ if ($server && $server->get('status')) {
+ $this->messenger->addStatus($this->t('LDAP server "@server" is configured and active.', [
+ '@server' => $server->label(),
+ ]));
+ }
+ else {
+ $this->messenger->addError($this->t('LDAP server not found or inactive.'));
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error testing connection: @message', ['@message' => $e->getMessage()]));
+ }
+ }
+
+ /**
+ * Submit handler para sincronizar cursos.
+ */
+ public function syncCourses(array &$form, FormStateInterface $form_state) {
+ if ($form_state->getErrors()) {
+ $this->messenger->addError($this->t('Please fix the errors in the form before running the synchronization.'));
+ return;
+ }
+
+ try {
+ $this->ldapCoursesSync->syncCourses();
+ $this->messenger->addStatus($this->t('Synchronization completed successfully. Check the logs for details.'));
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error during synchronization: @message', ['@message' => $e->getMessage()]));
+ }
+ }
+
+ /**
+ * Callback AJAX para atualizar lista de queries quando servidor muda.
+ */
+ public function updateLdapQueries(array &$form, FormStateInterface $form_state) {
+ return $form['ldap_query'];
+ }
+
+ /**
+ * Submit handler para adicionar um novo mapeamento.
+ */
+ public function addMapping(array &$form, FormStateInterface $form_state) {
+ $current_mappings = $form_state->getValue('mappings');
+
+ if (empty($current_mappings)) {
+ $config = $this->config('ldap_courses_sync.settings');
+ $current_mappings = $config->get('attribute_mappings') ?: $this->getDefaultMappings();
+ }
+
+ $processed_mappings = [];
+ foreach ($current_mappings as $mapping) {
+ $processed_mappings[] = [
+ 'field' => $mapping['field'] ?? '',
+ 'attribute' => $mapping['attribute'] ?? '',
+ 'mapping_type' => $mapping['mapping_type'] ?? 'simple',
+ 'remove' => FALSE,
+ ];
+ }
+
+ $processed_mappings[] = [
+ 'field' => '',
+ 'attribute' => '',
+ 'mapping_type' => 'simple',
+ 'remove' => FALSE,
+ ];
+
+ $form_state->setValue('mappings', $processed_mappings);
+ $form_state->set('mapping_count', count($processed_mappings));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Submit handler para remover mapeamentos selecionados.
+ */
+ public function removeSelectedMappings(array &$form, FormStateInterface $form_state) {
+ $mapping_values = $form_state->getValue('mappings');
+ $new_mappings = [];
+
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $mapping_data) {
+ if (empty($mapping_data['remove'])) {
+ $new_mappings[] = $mapping_data;
+ }
+ }
+ }
+
+ $form_state->setValue('mappings', $new_mappings);
+ $form_state->set('mapping_count', count($new_mappings));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Callback AJAX para atualizar mapeamentos de atributos.
+ */
+ public function updateAttributeMappings(array &$form, FormStateInterface $form_state) {
+ return $form['attribute_mapping'];
+ }
+
+ /**
+ * Submit handler to add a new role mapping.
+ */
+ public function addRoleMapping(array &$form, FormStateInterface $form_state) {
+ $current_mappings = $form_state->getValue('role_mappings');
+
+ if (empty($current_mappings)) {
+ $config = $this->config('ldap_courses_sync.settings');
+ $current_mappings = $config->get('role_mappings') ?: [];
+ }
+
+ $processed_mappings = [];
+ foreach ($current_mappings as $mapping) {
+ $processed_mappings[] = [
+ 'group_role' => $mapping['group_role'] ?? '',
+ 'source' => $mapping['source'] ?? 'ldap_attribute',
+ 'source_field' => $mapping['source_field'] ?? '',
+ 'values' => $mapping['values'] ?? '',
+ 'remove' => FALSE,
+ ];
+ }
+
+ $processed_mappings[] = [
+ 'group_role' => '',
+ 'source' => 'ldap_attribute',
+ 'source_field' => '',
+ 'values' => '',
+ 'remove' => FALSE,
+ ];
+
+ $form_state->setValue('role_mappings', $processed_mappings);
+ $form_state->set('role_mapping_count', count($processed_mappings));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Submit handler to remove selected role mappings.
+ */
+ public function removeSelectedRoleMappings(array &$form, FormStateInterface $form_state) {
+ $mapping_values = $form_state->getValue('role_mappings');
+ $new_mappings = [];
+
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $mapping_data) {
+ if (empty($mapping_data['remove'])) {
+ $new_mappings[] = $mapping_data;
+ }
+ }
+ }
+
+ $form_state->setValue('role_mappings', $new_mappings);
+ $form_state->set('role_mapping_count', count($new_mappings));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * AJAX callback to update role mapping fields.
+ */
+ public function updateRoleMappingFields(array &$form, FormStateInterface $form_state) {
+ return $form['role_mapping']['role_mapping_fields'];
+ }
+
+ /**
+ * Gets available roles for the selected group type.
+ */
+ protected function getGroupRoles($group_type_id) {
+ $roles = [];
+
+ if (empty($group_type_id)) {
+ return $roles;
+ }
+
+ try {
+ $group_type_storage = $this->entityTypeManager->getStorage('group_type');
+ $group_type = $group_type_storage->load($group_type_id);
+
+ if ($group_type) {
+ $group_role_storage = $this->entityTypeManager->getStorage('group_role');
+ $group_roles = $group_role_storage->loadByProperties([
+ 'group_type' => $group_type_id,
+ ]);
+
+ foreach ($group_roles as $role) {
+ $role_id = $role->id();
+
+ if (strpos($role_id, '-anonymous') !== FALSE || strpos($role_id, '-outsider') !== FALSE) {
+ continue;
+ }
+
+ $scope_label = '';
+
+ if (strpos($role_id, '_in') !== FALSE) {
+ $scope_label = ' (' . $this->t('Internal') . ')';
+ }
+ elseif (strpos($role_id, '_out') !== FALSE) {
+ $scope_label = ' (' . $this->t('Outsider') . ')';
+ }
+
+ $roles[$role_id] = $role->label() . $scope_label;
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading group roles: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $roles;
+ }
+
+ /**
+ * Gets available user fields for mapping.
+ */
+ protected function getUserFields() {
+ $fields = [];
+
+ try {
+ $field_manager = \Drupal::service('entity_field.manager');
+ $field_definitions = $field_manager->getFieldDefinitions('user', 'user');
+
+ foreach ($field_definitions as $field_name => $field_definition) {
+ if (in_array($field_name, ['uid', 'uuid', 'langcode', 'name', 'pass', 'mail', 'timezone', 'status', 'created', 'changed', 'access', 'login', 'init', 'roles', 'default_langcode'])) {
+ continue;
+ }
+
+ $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')';
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading user fields: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Obtém lista de queries LDAP para um servidor específico.
+ */
+ protected function getLdapQueriesForServer($server_id) {
+ $queries = [];
+
+ if (empty($server_id)) {
+ return $queries;
+ }
+
+ if (!$this->entityTypeManager->hasDefinition('ldap_query_entity')) {
+ $this->messenger->addWarning($this->t('The ldap_query module is not available. LDAP queries cannot be loaded.'));
+ return $queries;
+ }
+
+ try {
+ $query_storage = $this->entityTypeManager->getStorage('ldap_query_entity');
+ $query_entities = $query_storage->loadByProperties([
+ 'status' => TRUE,
+ 'server_id' => $server_id,
+ ]);
+
+ foreach ($query_entities as $query) {
+ $queries[$query->id()] = $query->label() . ' (' . $query->get('base_dn') . ')';
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading LDAP queries: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $queries;
+ }
+
+ /**
+ * Gets list of available group types.
+ */
+ protected function getGroupTypes() {
+ $group_types = [];
+
+ try {
+ $group_type_storage = $this->entityTypeManager->getStorage('group_type');
+ $group_type_entities = $group_type_storage->loadMultiple();
+
+ foreach ($group_type_entities as $group_type) {
+ $group_types[$group_type->id()] = $group_type->label() . ' (' . $group_type->id() . ')';
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading group types: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $group_types;
+ }
+
+ /**
+ * Gets fields from the selected group type.
+ */
+ protected function getGroupTypeFields($group_type_id) {
+ $fields = [
+ 'label' => $this->t('Label (group title)'),
+ ];
+
+ if (empty($group_type_id)) {
+ return $fields;
+ }
+
+ try {
+ $group_type_storage = $this->entityTypeManager->getStorage('group_type');
+ $group_type = $group_type_storage->load($group_type_id);
+
+ if (!$group_type) {
+ $this->messenger->addWarning($this->t('Group type "@type" not found. Please create it first or select a different group type.', [
+ '@type' => $group_type_id,
+ ]));
+ return $fields;
+ }
+
+ $field_manager = \Drupal::service('entity_field.manager');
+ $field_definitions = $field_manager->getFieldDefinitions('group', $group_type_id);
+
+ foreach ($field_definitions as $field_name => $field_definition) {
+ if (in_array($field_name, ['id', 'uuid', 'type', 'langcode', 'label', 'uid', 'created', 'changed', 'default_langcode'])) {
+ continue;
+ }
+
+ $fields[$field_name] = $field_definition->getLabel() . ' (' . $field_name . ')';
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error loading group type fields: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Callback AJAX to update group type actions when group type changes.
+ */
+ public function updateGroupTypeActions(array &$form, FormStateInterface $form_state) {
+ return $form['group_type'];
+ }
+
+ /**
+ * Submit handler to install default configuration.
+ */
+ public function installDefaultConfiguration(array &$form, FormStateInterface $form_state) {
+ $module_path = \Drupal::service('extension.list.module')->getPath('ldap_courses_sync');
+ $config_path = $module_path . '/config/optional';
+
+ if (!is_dir($config_path)) {
+ $this->messenger->addError($this->t('Configuration directory not found: @path', ['@path' => $config_path]));
+ return;
+ }
+
+ try {
+ $config_factory = \Drupal::configFactory();
+ $yaml_parser = \Drupal::service('serialization.yaml');
+ $files_imported = 0;
+
+ $import_order = [
+ 'field.storage.*.yml',
+ 'group.type.*.yml',
+ 'group.role.*.yml',
+ 'field.field.*.yml',
+ 'core.entity_*.yml',
+ ];
+
+ foreach ($import_order as $pattern) {
+ $files = glob($config_path . '/' . $pattern);
+ foreach ($files as $file) {
+ $config_name = basename($file, '.yml');
+
+ $existing_config = $config_factory->get($config_name);
+ if (!$existing_config->isNew()) {
+ $this->messenger->addWarning($this->t('Configuration @name already exists, skipping.', ['@name' => $config_name]));
+ continue;
+ }
+
+ $yaml_content = file_get_contents($file);
+ $data = $yaml_parser->decode($yaml_content);
+
+ $config_factory->getEditable($config_name)
+ ->setData($data)
+ ->save();
+
+ $files_imported++;
+ }
+ }
+
+ if ($files_imported > 0) {
+ \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
+ \Drupal::service('entity_type.bundle.info')->clearCachedBundles();
+ \Drupal::service('plugin.manager.field.widget')->clearCachedDefinitions();
+ \Drupal::service('plugin.manager.field.formatter')->clearCachedDefinitions();
+ \Drupal::service('config.factory')->clearStaticCache();
+ \Drupal::service('router.builder')->rebuild();
+
+ $this->messenger->addStatus($this->t('Successfully imported @count configuration files.', ['@count' => $files_imported]));
+ }
+ else {
+ $this->messenger->addWarning($this->t('No new configuration files were imported. All configurations already exist.'));
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error importing configuration: @message', ['@message' => $e->getMessage()]));
+ }
+ }
+
+}
diff --git a/modules/ldap_courses_sync/src/LdapCoursesSync.php b/modules/ldap_courses_sync/src/LdapCoursesSync.php
new file mode 100644
index 0000000..3ace5af
--- /dev/null
+++ b/modules/ldap_courses_sync/src/LdapCoursesSync.php
@@ -0,0 +1,1142 @@
+entityTypeManager = $entity_type_manager;
+ $this->configFactory = $config_factory;
+ $this->logger = $logger_factory->get('ldap_courses_sync');
+ $this->ldapBridge = $ldap_bridge;
+
+ // Check if entity type ldap_query_entity exists
+ if ($entity_type_manager->hasDefinition('ldap_query_entity')) {
+ $this->ldapQueryStorage = $entity_type_manager->getStorage('ldap_query_entity');
+ }
+ else {
+ $this->logger->warning('Entity type ldap_query_entity not found. Check if ldap_query module is properly installed.');
+ $this->ldapQueryStorage = NULL;
+ }
+ }
+
+ /**
+ * Synchronizes courses from LDAP to groups.
+ */
+ public function syncCourses() {
+ $this->logger->info('Starting courses synchronization using configured LDAP query.');
+
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ $bundle_id = $this->getBundleId();
+
+ $this->logger->info('Configuração atual - Server: @server, Query: @query, Group Type: @bundle', [
+ '@server' => $config->get('ldap_server_id') ?: 'não configurado',
+ '@query' => $config->get('ldap_query_id') ?: 'não configurado',
+ '@bundle' => $bundle_id,
+ ]);
+
+ try {
+ $courses = $this->fetchCoursesUsingQuery();
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro durante fetchCoursesUsingQuery: @message', ['@message' => $e->getMessage()]);
+ throw $e;
+ }
+
+ if (empty($courses)) {
+ $this->logger->warning('Nenhum curso encontrado no LDAP.');
+ return;
+ }
+
+ $this->logger->info('Iniciando sincronização de @count cursos', ['@count' => count($courses)]);
+
+ $entity_storage = $this->getEntityStorage();
+ $bundle_field = $this->getBundleField();
+ $existing_entities = $entity_storage->loadByProperties([$bundle_field => $bundle_id]);
+
+ // Cria um mapa de códigos existentes
+ $existing_codes = [];
+ foreach ($existing_entities as $entity) {
+ if ($entity->hasField('field_course_code') && !$entity->get('field_course_code')->isEmpty()) {
+ $code = $entity->get('field_course_code')->value;
+ $existing_codes[$code] = $entity;
+ }
+ }
+
+ $created = 0;
+ $updated = 0;
+
+ foreach ($courses as $course_data) {
+ $code = $course_data['code'];
+ $name = $course_data['name'];
+ $phone = $course_data['phone'];
+ $mail = $course_data['mail'];
+
+ // Campos extras (incluindo referências de usuário)
+ $extra_fields = [];
+ foreach ($course_data as $field => $value) {
+ if (!in_array($field, ['code', 'name', 'phone', 'mail', '_ldap_members'])) {
+ $extra_fields[$field] = $value;
+ }
+ }
+
+ $this->logger->info('Processando curso - Código: @code, Nome: @name', [
+ '@code' => $code,
+ '@name' => $name,
+ ]);
+
+ if (isset($existing_codes[$code])) {
+ // Atualiza entidade existente
+ $this->logger->info('Atualizando entidade existente com código: @code', ['@code' => $code]);
+ $entity = $existing_codes[$code];
+ $name_field = $this->getNameField();
+
+ if ($name_field === 'label') {
+ $entity->set('label', $name);
+ }
+ else {
+ $entity->setName($name);
+ }
+
+ if ($entity->hasField('field_course_phone')) {
+ $entity->set('field_course_phone', $phone);
+ }
+ if ($entity->hasField('field_course_mail')) {
+ $entity->set('field_course_mail', $mail);
+ }
+
+ foreach ($extra_fields as $field => $value) {
+ if ($entity->hasField($field)) {
+ $entity->set($field, $value);
+ $this->logger->debug('Campo @field atualizado com valor @value', [
+ '@field' => $field,
+ '@value' => $value ?? '',
+ ]);
+ }
+ else {
+ $this->logger->warning('Campo @field não existe na entidade', [
+ '@field' => $field,
+ ]);
+ }
+ }
+
+ try {
+ $entity->save();
+ $updated++;
+ $this->logger->info('Entidade atualizada com sucesso: @code', ['@code' => $code]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao atualizar entidade @code: @error', [
+ '@code' => $code,
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ else {
+ // Cria nova entidade
+ $this->logger->info('Criando nova entidade com código: @code', ['@code' => $code]);
+ try {
+ $bundle_field = $this->getBundleField();
+ $name_field = $this->getNameField();
+
+ $entity_data = [
+ $bundle_field => $bundle_id,
+ $name_field => $name,
+ 'field_course_code' => $code,
+ 'field_course_phone' => $phone,
+ 'field_course_mail' => $mail,
+ 'uid' => 1,
+ ];
+
+ foreach ($extra_fields as $field => $value) {
+ $entity_data[$field] = $value;
+ $this->logger->debug('Campo @field configurado com valor @value', [
+ '@field' => $field,
+ '@value' => $value ?? '',
+ ]);
+ }
+
+ $entity = $entity_storage->create($entity_data);
+ $entity->save();
+ $created++;
+ $this->logger->info('Entidade criada com sucesso: @code (ID: @id)', [
+ '@code' => $code,
+ '@id' => $entity->id(),
+ ]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao criar entidade @code: @error', [
+ '@code' => $code,
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ if ($created > 0 || $updated > 0) {
+ $this->logger->info('Sincronização concluída com sucesso. Criados: @created, Atualizados: @updated', [
+ '@created' => $created,
+ '@updated' => $updated,
+ ]);
+ }
+ else {
+ $this->logger->info('Nenhum curso criado ou atualizado.');
+ }
+
+ // Sincroniza membros dos grupos via atributo LDAP
+ $this->syncMembersFromLdapAttribute($courses);
+ }
+
+ /**
+ * Busca entidade de curso pelo código.
+ *
+ * @param string $course_code
+ * Código do curso.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface|null
+ * Entidade grupo ou NULL se não encontrado.
+ */
+ public function getCourseByCode($course_code) {
+ if (empty($course_code)) {
+ return NULL;
+ }
+
+ $bundle_id = $this->getBundleId();
+ $bundle_field = $this->getBundleField();
+ $entity_storage = $this->getEntityStorage();
+
+ $entities = $entity_storage->loadByProperties([
+ $bundle_field => $bundle_id,
+ 'field_course_code' => $course_code,
+ ]);
+
+ return !empty($entities) ? reset($entities) : NULL;
+ }
+
+ /**
+ * Busca cursos usando a query LDAP configurada.
+ *
+ * @return array
+ * Array de cursos com código e descrição.
+ */
+ protected function fetchCoursesUsingQuery() {
+ $courses = [];
+
+ $this->logger->info('Iniciando fetchCoursesUsingQuery...');
+
+ if (!$this->ldapQueryStorage) {
+ $this->logger->error('Storage de queries LDAP não está disponível. Verifique se o módulo ldap_query está instalado.');
+ return $courses;
+ }
+
+ $this->logger->info('Storage de queries LDAP está disponível.');
+
+ try {
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ $query_id = $config->get('ldap_query_id') ?: 'course_sync';
+
+ $this->logger->info('Tentando carregar query LDAP: @query_id', ['@query_id' => $query_id]);
+
+ $query_entity = $this->ldapQueryStorage->load($query_id);
+
+ if (!$query_entity) {
+ $this->logger->error('Query LDAP "@query_id" não encontrada. Configure a query via /admin/config/local-modules/ldap-courses-sync', [
+ '@query_id' => $query_id,
+ ]);
+ return $courses;
+ }
+
+ $this->logger->info('Query LDAP carregada com sucesso: @label', ['@label' => $query_entity->label()]);
+
+ if (!$query_entity->get('status')) {
+ $this->logger->error('Query LDAP "@query_id" está desabilitada.', [
+ '@query_id' => $query_id,
+ ]);
+ return $courses;
+ }
+
+ $this->logger->info('Usando query LDAP configurada: @query_id (@label)', [
+ '@query_id' => $query_id,
+ '@label' => $query_entity->label(),
+ ]);
+
+ $this->logger->info('Executando query LDAP...');
+
+ try {
+ $server_id = $query_entity->getServerId();
+ $base_dns = $query_entity->getProcessedBaseDns();
+ $filter = $query_entity->getFilter();
+ $attributes = $query_entity->getProcessedAttributes();
+
+ $this->logger->info('Parâmetros da query - Server: @server, Base DN: @base_dn, Filter: @filter, Attributes: @attrs', [
+ '@server' => $server_id,
+ '@base_dn' => !empty($base_dns) ? implode(', ', $base_dns) : 'vazio',
+ '@filter' => $filter,
+ '@attrs' => !empty($attributes) ? implode(', ', $attributes) : 'vazio',
+ ]);
+
+ $this->ldapBridge->setServerById($server_id);
+
+ if (!$this->ldapBridge->bind()) {
+ throw new \Exception('Falha ao conectar ao servidor LDAP');
+ }
+
+ $this->logger->info('Conectado ao servidor LDAP com sucesso');
+
+ $all_results = [];
+ foreach ($base_dns as $base_dn) {
+ $this->logger->info('Executando busca em Base DN: @base_dn', ['@base_dn' => $base_dn]);
+
+ try {
+ $ldap = $this->ldapBridge->get();
+ $this->logger->info('Obtida instância LDAP do bridge');
+
+ // Garante que o atributo de membros está incluído na query
+ $member_sync_on = $config->get('member_sync_enabled') ?? TRUE;
+ if ($member_sync_on) {
+ $member_attr = $config->get('member_attribute') ?: 'member';
+ if (!empty($attributes) && !in_array($member_attr, $attributes)) {
+ $attributes[] = $member_attr;
+ }
+ }
+
+ $attr_options = [];
+ if (!empty($attributes)) {
+ $attr_options = ['filter' => $attributes];
+ }
+
+ $this->logger->info('Criando query Symfony LDAP...');
+ $query = $ldap->query($base_dn, $filter, $attr_options);
+
+ $this->logger->info('Executando query Symfony LDAP...');
+ $base_results = $query->execute();
+
+ $this->logger->info('Query executada. Tipo de resultado: @type', ['@type' => gettype($base_results)]);
+
+ if ($base_results instanceof \Traversable) {
+ $base_results = iterator_to_array($base_results);
+ }
+
+ $this->logger->info('Resultados encontrados: @count', ['@count' => count($base_results)]);
+
+ if (!empty($base_results) && is_array($base_results)) {
+ $all_results = array_merge($all_results, $base_results);
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro na busca LDAP para Base DN @base_dn: @message', [
+ '@base_dn' => $base_dn,
+ '@message' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $results = $all_results;
+ $this->logger->info('Query LDAP executada com sucesso. Total de resultados: @count', ['@count' => count($results)]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]);
+ $this->logger->error('Stack trace: @trace', ['@trace' => $e->getTraceAsString()]);
+ throw $e;
+ }
+
+ $this->logger->info('Verificando resultados da query...');
+
+ if (empty($results)) {
+ $this->logger->warning('Query LDAP não retornou resultados.');
+ return $courses;
+ }
+
+ $this->logger->info('Query LDAP retornou @count resultados', ['@count' => count($results)]);
+
+ $courses = $this->processLdapResults($results);
+
+ $this->logger->info('Processados @count cursos via query LDAP', ['@count' => count($courses)]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]);
+ }
+
+ return $courses;
+ }
+
+ /**
+ * Sincroniza membros dos grupos baseado no código de grupo dos usuários.
+ */
+ public function syncGroupMembers() {
+ $this->logger->info('Starting group membership synchronization.');
+
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ $role_mapping_enabled = $config->get('role_mapping_enabled') ?? FALSE;
+ $role_mappings = $config->get('role_mappings') ?? [];
+
+ if (!$role_mapping_enabled) {
+ $this->logger->info('Role mapping is disabled. Skipping group membership synchronization.');
+ return;
+ }
+
+ if (empty($role_mappings)) {
+ $this->logger->warning('Role mapping is enabled but no mappings are configured. Skipping synchronization.');
+ return;
+ }
+
+ $user_storage = $this->entityTypeManager->getStorage('user');
+ $query = $user_storage->getQuery()
+ ->condition('status', 1)
+ ->condition('uid', 0, '>')
+ ->accessCheck(FALSE);
+
+ $uids = $query->execute();
+
+ if (empty($uids)) {
+ $this->logger->warning('No active users found.');
+ return;
+ }
+
+ $users = $user_storage->loadMultiple($uids);
+ $this->logger->info('Processing @count users for group membership', ['@count' => count($users)]);
+
+ $group_storage = $this->entityTypeManager->getStorage('group');
+ $group_type_id = $this->getBundleId();
+ $groups = $group_storage->loadByProperties(['type' => $group_type_id]);
+
+ $this->logger->info('Found @count groups', ['@count' => count($groups)]);
+
+ $added = 0;
+ $removed = 0;
+ $role_updated = 0;
+ $skipped = 0;
+ $already_member = 0;
+ $matched = 0;
+
+ foreach ($users as $user) {
+ try {
+ $username = $user->getAccountName();
+ $expected_memberships = [];
+
+ foreach ($groups as $group) {
+ $group_role = $this->determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings);
+
+ if ($group_role !== NULL) {
+ $expected_memberships[$group->id()] = $group_role;
+ }
+ }
+
+ if (!empty($expected_memberships)) {
+ $matched++;
+ }
+
+ foreach ($expected_memberships as $group_id => $expected_role) {
+ $group = $groups[$group_id];
+ $membership = $group->getMember($user);
+
+ if ($membership) {
+ $current_roles = $membership->getRoles();
+ $current_role_ids = array_map(function ($role) {
+ return $role->id();
+ }, $current_roles);
+
+ if (!in_array($expected_role, $current_role_ids)) {
+ try {
+ foreach ($current_role_ids as $role_id) {
+ if ($role_id !== 'member' && $role_id !== $expected_role) {
+ $membership->removeRole($role_id);
+ }
+ }
+ if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) {
+ $membership->addRole($expected_role);
+ }
+ $membership->save();
+ $role_updated++;
+ $this->logger->info('Updated role for user @username in group @group to @role', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ '@role' => $expected_role,
+ ]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to update role for user @username in group @group: @error', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ else {
+ $already_member++;
+ }
+ }
+ else {
+ try {
+ $values = [];
+ if ($expected_role !== 'member' && strpos($expected_role, '-member') === FALSE) {
+ $values['group_roles'] = [$expected_role];
+ }
+ $group->addMember($user, $values);
+ $added++;
+ $this->logger->info('Added user @username to group @group with role @role', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ '@role' => $expected_role,
+ ]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to add user @username to group @group: @error', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ $skipped++;
+ }
+ }
+ }
+
+ foreach ($groups as $group) {
+ if (!isset($expected_memberships[$group->id()])) {
+ $membership = $group->getMember($user);
+ if ($membership) {
+ try {
+ $group->removeMember($user);
+ $removed++;
+ $this->logger->info('Removed user @username from group @group', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ ]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to remove user @username from group @group: @error', [
+ '@username' => $username,
+ '@group' => $group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Error processing user @uid: @error', [
+ '@uid' => $user->id(),
+ '@error' => $e->getMessage(),
+ ]);
+ $skipped++;
+ }
+ }
+
+ $this->logger->info('Group membership synchronization completed. Matched: @matched, Added: @added, Already member: @already, Removed: @removed, Role updated: @role_updated, Skipped: @skipped', [
+ '@matched' => $matched,
+ '@added' => $added,
+ '@already' => $already_member,
+ '@removed' => $removed,
+ '@role_updated' => $role_updated,
+ '@skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * Sincroniza membros dos cursos usando o atributo member do LDAP.
+ *
+ * Para cada curso, lê a lista de DNs do atributo configurado
+ * (padrão: 'member'), resolve cada DN para um usuário Drupal e gerencia
+ * as associações de membros do grupo (adiciona e remove conforme necessário).
+ *
+ * @param array $courses
+ * Array de dados dos cursos processados, com chave '_ldap_members'.
+ */
+ public function syncMembersFromLdapAttribute(array $courses) {
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+
+ if (!($config->get('member_sync_enabled') ?? TRUE)) {
+ $this->logger->info('Member sync from LDAP attribute is disabled.');
+ return;
+ }
+
+ $this->logger->info('Starting member synchronization from LDAP attribute.');
+
+ $group_storage = $this->entityTypeManager->getStorage('group');
+ $user_storage = $this->entityTypeManager->getStorage('user');
+ $bundle_id = $this->getBundleId();
+ $bundle_field = $this->getBundleField();
+
+ $total_added = 0;
+ $total_removed = 0;
+ $total_not_found = 0;
+
+ foreach ($courses as $course_data) {
+ $code = $course_data['code'];
+ $ldap_members = $course_data['_ldap_members'] ?? [];
+
+ if (empty($ldap_members)) {
+ $this->logger->debug('No LDAP members for course @code.', ['@code' => $code]);
+ }
+
+ // Localiza a entidade grupo correspondente
+ $groups = $group_storage->loadByProperties([
+ $bundle_field => $bundle_id,
+ 'field_course_code' => $code,
+ ]);
+
+ if (empty($groups)) {
+ $this->logger->warning('Course with code @code not found for member sync.', ['@code' => $code]);
+ continue;
+ }
+
+ $group = reset($groups);
+
+ // Resolve DNs/UIDs LDAP para IDs de usuários Drupal
+ $expected_uids = [];
+ foreach ($ldap_members as $member_value) {
+ $username = $member_value;
+ // Se parece com um DN (contém '='), extrai o uid
+ if (strpos($member_value, '=') !== FALSE) {
+ if (preg_match('/uid=([^,]+)/i', $member_value, $matches)) {
+ $username = $matches[1];
+ }
+ else {
+ $this->logger->debug('Could not extract uid from DN: @dn', ['@dn' => $member_value]);
+ continue;
+ }
+ }
+
+ $uid = $this->getUserIdByUsername($username);
+ if ($uid) {
+ $expected_uids[$uid] = TRUE;
+ }
+ else {
+ $this->logger->debug('User @username (from @dn) not found in Drupal.', [
+ '@username' => $username,
+ '@dn' => $member_value,
+ ]);
+ $total_not_found++;
+ }
+ }
+
+ // Obtém membros atuais do grupo
+ $current_memberships = $group->getMembers();
+ $current_uids = [];
+ foreach ($current_memberships as $membership) {
+ $member_entity = $membership->getUser();
+ if ($member_entity) {
+ $current_uids[$member_entity->id()] = TRUE;
+ }
+ }
+
+ // Adiciona novos membros
+ foreach (array_keys($expected_uids) as $uid) {
+ if (!isset($current_uids[$uid])) {
+ $user = $user_storage->load($uid);
+ if ($user) {
+ try {
+ $group->addMember($user);
+ $total_added++;
+ $this->logger->debug('Added user @uid to course @group.', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ ]);
+ // Append group to field_user_courses if not already there.
+ if ($user->hasField('field_user_courses')) {
+ $existing = $user->get('field_user_courses')->getValue();
+ $already_set = FALSE;
+ foreach ($existing as $ref) {
+ if ($ref['target_id'] == $group->id()) {
+ $already_set = TRUE;
+ break;
+ }
+ }
+ if (!$already_set) {
+ self::$syncing = TRUE;
+ try {
+ $user->get('field_user_courses')->appendItem(['target_id' => $group->id()]);
+ $user->save();
+ }
+ finally {
+ self::$syncing = FALSE;
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to add user @uid to course @group: @error', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+ }
+
+ // Remove membros que não estão mais no LDAP
+ foreach (array_keys($current_uids) as $uid) {
+ if (!isset($expected_uids[$uid])) {
+ $user = $user_storage->load($uid);
+ if ($user) {
+ try {
+ $group->removeMember($user);
+ $total_removed++;
+ $this->logger->debug('Removed user @uid from course @group.', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ ]);
+ // Remove group from field_user_courses if present.
+ if ($user->hasField('field_user_courses')) {
+ $existing = $user->get('field_user_courses')->getValue();
+ $updated = array_values(array_filter($existing, fn($ref) => $ref['target_id'] != $group->id()));
+ if (count($updated) !== count($existing)) {
+ self::$syncing = TRUE;
+ try {
+ $user->set('field_user_courses', $updated);
+ $user->save();
+ }
+ finally {
+ self::$syncing = FALSE;
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to remove user @uid from course @group: @error', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+ }
+ }
+
+ $this->logger->info('Member sync completed. Added: @added, Removed: @removed, Not found in Drupal: @not_found', [
+ '@added' => $total_added,
+ '@removed' => $total_removed,
+ '@not_found' => $total_not_found,
+ ]);
+ }
+
+ /**
+ * Determina o papel (role) de um usuário em um grupo baseado nos mapeamentos.
+ */
+ protected function determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings) {
+ if (!$role_mapping_enabled || empty($role_mappings)) {
+ return NULL;
+ }
+
+ foreach ($role_mappings as $mapping) {
+ $group_role = $mapping['group_role'] ?? '';
+ $source = $mapping['source'] ?? 'user_field';
+ $source_field = $mapping['source_field'] ?? '';
+ $values = $mapping['values'] ?? [];
+ $group_field = $mapping['group_field'] ?? '';
+
+ if (empty($group_role) || empty($source_field)) {
+ continue;
+ }
+
+ $user_value = NULL;
+
+ if ($source === 'user_field') {
+ if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty()) {
+ $field = $user->get($source_field);
+ $field_type = $field->getFieldDefinition()->getType();
+ if (in_array($field_type, ['entity_reference', 'entity_reference_revisions'])) {
+ $user_value = $field->target_id;
+ }
+ else {
+ $user_value = $field->value;
+ }
+ }
+ }
+ elseif ($source === 'ldap_attribute') {
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ $ldap_user_data = $this->fetchUserLdapAttribute($user, $source_field, $config);
+ if ($ldap_user_data !== NULL) {
+ $user_value = $ldap_user_data;
+ }
+ }
+ elseif ($source === 'group_field_match') {
+ if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty() &&
+ $group->hasField($group_field) && !$group->get($group_field)->isEmpty()) {
+
+ $user_field = $user->get($source_field);
+ $group_field_obj = $group->get($group_field);
+
+ $user_field_type = $user_field->getFieldDefinition()->getType();
+ if (in_array($user_field_type, ['entity_reference', 'entity_reference_revisions'])) {
+ $user_value = $user_field->target_id;
+ }
+ else {
+ $user_value = $user_field->value;
+ }
+
+ $group_field_type = $group_field_obj->getFieldDefinition()->getType();
+ if (in_array($group_field_type, ['entity_reference', 'entity_reference_revisions'])) {
+ $group_value = $group_field_obj->target_id;
+ }
+ else {
+ $group_value = $group_field_obj->value;
+ }
+
+ if ($user_value !== NULL && $group_value !== NULL &&
+ strcasecmp(trim($user_value), trim($group_value)) === 0) {
+ return $group_role;
+ }
+ }
+ continue;
+ }
+
+ if ($user_value !== NULL && !empty($values)) {
+ foreach ($values as $mapping_value) {
+ if (strcasecmp(trim($user_value), trim($mapping_value)) === 0) {
+ return $group_role;
+ }
+ }
+ }
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Busca um atributo LDAP específico para um usuário.
+ */
+ protected function fetchUserLdapAttribute($user, $attribute, $config) {
+ try {
+ $host = $config->get('ldap_host');
+ $port = $config->get('ldap_port');
+ $base_dn = $config->get('users_base_dn') ?? $config->get('ldap_base_dn');
+ $bind_dn = $config->get('ldap_bind_dn');
+ $bind_password = $config->get('ldap_bind_password');
+ $users_filter = $config->get('users_filter') ?? '(objectClass=person)';
+
+ if (empty($host) || empty($base_dn)) {
+ return NULL;
+ }
+
+ $ldap_connection = ldap_connect($host, $port);
+ if (!$ldap_connection) {
+ return NULL;
+ }
+
+ ldap_set_option($ldap_connection, LDAP_OPT_PROTOCOL_VERSION, 3);
+ ldap_set_option($ldap_connection, LDAP_OPT_REFERRALS, 0);
+
+ if (!empty($bind_dn) && !empty($bind_password)) {
+ $bind = @ldap_bind($ldap_connection, $bind_dn, $bind_password);
+ }
+ else {
+ $bind = @ldap_bind($ldap_connection);
+ }
+
+ if (!$bind) {
+ ldap_close($ldap_connection);
+ return NULL;
+ }
+
+ $username = $user->getAccountName();
+ $filter = "(&{$users_filter}(uid={$username}))";
+ $search = ldap_search($ldap_connection, $base_dn, $filter, [$attribute]);
+
+ if (!$search) {
+ ldap_close($ldap_connection);
+ return NULL;
+ }
+
+ $entries = ldap_get_entries($ldap_connection, $search);
+ ldap_close($ldap_connection);
+
+ if (!empty($entries) && $entries['count'] > 0 && isset($entries[0][$attribute])) {
+ return $entries[0][$attribute][0] ?? NULL;
+ }
+
+ return NULL;
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Error fetching LDAP attribute @attribute for user @username: @error', [
+ '@attribute' => $attribute,
+ '@username' => $user->getAccountName(),
+ '@error' => $e->getMessage(),
+ ]);
+ return NULL;
+ }
+ }
+
+ /**
+ * Processes LDAP results using configured attribute mappings.
+ *
+ * @param array $results
+ * Array of LDAP results.
+ *
+ * @return array
+ * Array of processed courses.
+ */
+ protected function processLdapResults(array $results) {
+ $courses = [];
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ $attribute_mappings = $config->get('attribute_mappings') ?: [];
+
+ $code_mapping = NULL;
+ foreach ($attribute_mappings as $mapping) {
+ if ($mapping['field'] === 'field_course_code') {
+ $code_mapping = $mapping;
+ break;
+ }
+ }
+
+ if (!$code_mapping) {
+ $this->logger->error('No field_course_code mapping found. Cannot process results.');
+ return $courses;
+ }
+
+ $this->logger->debug('Processing @count LDAP results with @mappings mappings', [
+ '@count' => count($results),
+ '@mappings' => count($attribute_mappings),
+ ]);
+
+ foreach ($results as $entry) {
+ $course_data = [];
+
+ foreach ($attribute_mappings as $mapping) {
+ $field = $mapping['field'];
+ $attribute = $mapping['attribute'];
+ $mapping_type = $mapping['mapping_type'] ?? 'simple';
+
+ $value = NULL;
+ if ($entry->hasAttribute($attribute)) {
+ $attr_values = $entry->getAttribute($attribute);
+ if (is_array($attr_values) && isset($attr_values[0])) {
+ $value = $attr_values[0];
+ }
+ elseif (!empty($attr_values)) {
+ $value = $attr_values;
+ }
+ }
+ elseif ($mapping_type === 'user_reference') {
+ $this->logger->warning('Atributo LDAP "@attribute" não encontrado na entrada para o campo "@field". Verifique se o atributo está incluído na LDAP query.', [
+ '@attribute' => $attribute,
+ '@field' => $field,
+ ]);
+ }
+
+ if ($mapping_type === 'department_reference') {
+ if (!empty($value)) {
+ $dept_id = $this->getDepartmentIdByCode($value);
+ $course_data[$field] = $dept_id;
+ }
+ else {
+ $course_data[$field] = NULL;
+ }
+ }
+ elseif ($mapping_type === 'user_reference') {
+ if (!empty($value)) {
+ $username = $value;
+ // Se parece com DN (contém '='), tenta extrair o uid= ou o primeiro RDN.
+ if (strpos($value, '=') !== FALSE) {
+ if (preg_match('/uid=([^,]+)/i', $value, $matches)) {
+ $username = $matches[1];
+ }
+ else {
+ // Extrai o valor do primeiro RDN (ex.: cn=João Silva,... → João Silva)
+ preg_match('/^[^=]+=([^,]+)/i', $value, $matches);
+ $username = $matches[1] ?? $value;
+ $this->logger->warning('Campo "@field": DN sem uid= encontrado ("@dn"). Usando primeiro RDN "@username" como username.', [
+ '@field' => $field,
+ '@dn' => $value,
+ '@username' => $username,
+ ]);
+ }
+ }
+
+ $user_id = $this->getUserIdByUsername($username);
+
+ if ($user_id) {
+ $course_data[$field] = $user_id;
+ }
+ else {
+ $this->logger->warning('Campo "@field": usuário "@username" (valor LDAP: "@value") não encontrado no Drupal. Campo ficará vazio.', [
+ '@field' => $field,
+ '@username' => $username,
+ '@value' => $value,
+ ]);
+ $course_data[$field] = NULL;
+ }
+ }
+ else {
+ $course_data[$field] = NULL;
+ }
+ }
+ else {
+ $course_data[$field] = $value;
+ }
+ }
+
+ $code = $course_data['field_course_code'] ?? NULL;
+ if ($code) {
+ $result = [
+ 'code' => $code,
+ 'name' => $course_data['label'] ?? $course_data['name'] ?? '',
+ 'phone' => $course_data['field_course_phone'] ?? '',
+ 'mail' => $course_data['field_course_mail'] ?? '',
+ ];
+
+ // Captura lista de membros do atributo LDAP configurado
+ $member_attribute = $config->get('member_attribute') ?: 'member';
+ if ($entry->hasAttribute($member_attribute)) {
+ $result['_ldap_members'] = $entry->getAttribute($member_attribute) ?? [];
+ }
+ else {
+ $result['_ldap_members'] = [];
+ }
+
+ $basic_course_fields = ['field_course_code', 'label', 'name', 'field_course_phone', 'field_course_mail'];
+ foreach ($course_data as $key => $value) {
+ if (!in_array($key, $basic_course_fields)) {
+ $result[$key] = $value;
+ }
+ }
+
+ $courses[] = $result;
+ }
+ }
+
+ return $courses;
+ }
+
+ /**
+ * Gets Drupal user ID by username.
+ */
+ protected function getUserIdByUsername($username) {
+ if (empty($username)) {
+ return NULL;
+ }
+
+ $user_storage = $this->entityTypeManager->getStorage('user');
+ $users = $user_storage->loadByProperties(['name' => $username]);
+
+ if (!empty($users)) {
+ $user = reset($users);
+ return $user->id();
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Returns the Drupal group ID for a department with the given code.
+ */
+ protected function getDepartmentIdByCode(string $code): ?int {
+ if (empty($code)) {
+ return NULL;
+ }
+ $groups = $this->entityTypeManager->getStorage('group')
+ ->loadByProperties(['field_dept_code' => $code]);
+ if (!empty($groups)) {
+ return (int) reset($groups)->id();
+ }
+ $this->logger->debug('Department with field_dept_code @code not found.', ['@code' => $code]);
+ return NULL;
+ }
+
+ /**
+ * Returns the group entity storage.
+ */
+ protected function getEntityStorage() {
+ return $this->entityTypeManager->getStorage('group');
+ }
+
+ /**
+ * Returns the group type ID from configuration.
+ */
+ protected function getBundleId() {
+ $config = $this->configFactory->get('ldap_courses_sync.settings');
+ return $config->get('group_type_id') ?: 'course';
+ }
+
+ /**
+ * Returns the name field for groups.
+ */
+ protected function getNameField() {
+ return 'label';
+ }
+
+ /**
+ * Returns the bundle field name for groups.
+ */
+ protected function getBundleField() {
+ return 'type';
+ }
+
+}
diff --git a/modules/ldap_courses_sync/src/Plugin/EntityReferenceSelection/CourseSelection.php b/modules/ldap_courses_sync/src/Plugin/EntityReferenceSelection/CourseSelection.php
new file mode 100644
index 0000000..999ac35
--- /dev/null
+++ b/modules/ldap_courses_sync/src/Plugin/EntityReferenceSelection/CourseSelection.php
@@ -0,0 +1,31 @@
+condition('type', 'course');
+ return $query;
+ }
+
+}
diff --git a/modules/ldap_courses_sync/translations/ldap_courses_sync.pt-br.po b/modules/ldap_courses_sync/translations/ldap_courses_sync.pt-br.po
new file mode 100644
index 0000000..a29cbb5
--- /dev/null
+++ b/modules/ldap_courses_sync/translations/ldap_courses_sync.pt-br.po
@@ -0,0 +1,54 @@
+# Portuguese (Brazil) translation for LDAP Courses Sync module
+# Copyright (c) 2026
+# This file is distributed under the same license as the LDAP Courses Sync module.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: LDAP Courses Sync 1.0.0\n"
+"POT-Creation-Date: 2026-03-02 00:00+0000\n"
+"PO-Revision-Date: 2026-03-02 00:00+0000\n"
+"Language-Team: Portuguese (Brazil)\n"
+"Language: pt-br\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: ldap_courses_sync.routing.yml
+msgid "LDAP Courses Sync"
+msgstr "Sincronização de Cursos LDAP"
+
+#: ldap_courses_sync.links.task.yml
+msgid "Courses Sync"
+msgstr "Sincronização de Cursos"
+
+#: ldap_courses_sync.module
+msgid "This module synchronizes courses from an LDAP server to groups through cron."
+msgstr "Este módulo sincroniza cursos de um servidor LDAP para grupos através do cron."
+
+#: ldap_courses_sync.install
+msgid "Please ensure your group type has the required fields: field_course_code, field_course_phone, field_course_mail, and field_course_department (for department reference)."
+msgstr "Certifique-se de que o tipo de grupo possui os campos obrigatórios: field_course_code, field_course_phone, field_course_mail e field_course_department (para referência de departamento)."
+
+msgid "LDAP Courses Sync module uninstalled."
+msgstr "Módulo LDAP Courses Sync desinstalado."
+
+#: src/Form/LdapCoursesSyncConfigForm.php
+
+msgid "Select the group type where courses will be synchronized.
Required fields: field_course_code, field_course_phone, field_course_mail"
+msgstr "Selecione o tipo de grupo onde os cursos serão sincronizados.
Campos obrigatórios: field_course_code, field_course_phone, field_course_mail"
+
+msgid "The default \"course\" group type is not installed."
+msgstr "O tipo de grupo padrão \"course\" não está instalado."
+
+msgid "The button above will create:\n - Group type \"course\" with all required fields
\n - User fields for course synchronization
\n - Default group roles and displays
\n
Or you can create a custom group type manually."
+msgstr "O botão acima irá criar:\n - Tipo de grupo \"course\" com todos os campos necessários
\n - Campos de usuário para sincronização de cursos
\n - Papéis de grupo padrão e exibições
\n
Ou você pode criar um tipo de grupo personalizado manualmente."
+
+msgid "Select the LDAP server configured for courses synchronization."
+msgstr "Selecione o servidor LDAP configurado para sincronização de cursos."
+
+msgid "Select the LDAP query that will be used to search for courses."
+msgstr "Selecione a query LDAP que será usada para buscar cursos."
+
+msgid "Synchronize Courses"
+msgstr "Sincronizar Cursos"
diff --git a/src/Form/GlobalAccessRuleForm.php b/src/Form/GlobalAccessRuleForm.php
index 52806a7..8088bd4 100644
--- a/src/Form/GlobalAccessRuleForm.php
+++ b/src/Form/GlobalAccessRuleForm.php
@@ -102,6 +102,9 @@ class GlobalAccessRuleForm extends AccessRuleFormBase {
if ($this->moduleHandler->moduleExists('ldap_research_groups_sync')) {
$options['research_groups'] = $this->t('Research Groups');
}
+ if ($this->moduleHandler->moduleExists('ldap_courses_sync')) {
+ $options['courses'] = $this->t('Courses');
+ }
$form['group_type'] = [
'#type' => 'select',
@@ -131,6 +134,7 @@ class GlobalAccessRuleForm extends AccessRuleFormBase {
protected function getConfigName(): string {
return match($this->groupType) {
'research_groups' => 'ldap_research_groups_sync.settings',
+ 'courses' => 'ldap_courses_sync.settings',
default => 'ldap_departments_sync.settings',
};
}
@@ -141,6 +145,7 @@ class GlobalAccessRuleForm extends AccessRuleFormBase {
protected function getDefaultGroupTypeId(): string {
return match($this->groupType) {
'research_groups' => 'research_group',
+ 'courses' => 'course',
default => 'departments',
};
}
diff --git a/src/Form/UnifiedAccessRulesForm.php b/src/Form/UnifiedAccessRulesForm.php
index 6327575..0a29153 100644
--- a/src/Form/UnifiedAccessRulesForm.php
+++ b/src/Form/UnifiedAccessRulesForm.php
@@ -33,6 +33,11 @@ class UnifiedAccessRulesForm extends FormBase {
'module' => 'ldap_research_groups_sync',
'label' => 'Research Groups',
],
+ 'courses' => [
+ 'config' => 'ldap_courses_sync.settings',
+ 'module' => 'ldap_courses_sync',
+ 'label' => 'Courses',
+ ],
];
/**