commit 346b897e257186d1950ffb6f85671dd353336092
Author: Quintino A. G. Souza
Date: Sat Feb 28 09:55:54 2026 -0300
Inicializa módulo base ldap_groups_sync
Cria super-módulo com infraestrutura compartilhada de regras de acesso
para os módulos de sincronização LDAP de grupos.
- GroupAccessRulesService: serviço parametrizável por config name
- AccessRulesFormBase: listagem/remoção de regras (classe abstrata)
- AccessRuleFormBase: formulário modal de criação/edição (classe abstrata)
- Sub-módulos ldap_departments_sync e ldap_research_groups_sync refatorados
para estender as classes base com subclasses mínimas
- Traduções pt-br centralizadas em ldap_groups_sync.pt-br.po
Co-Authored-By: Claude Sonnet 4.6
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0045428
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+# LDAP Groups Sync
+
+Módulo base que provê a infraestrutura compartilhada de **regras de acesso** para os módulos de sincronização LDAP de grupos.
+
+Não realiza sincronização por si só — funciona como biblioteca de classes abstratas instanciada pelos sub-módulos que ficam dentro deste repositório.
+
+## Sub-módulos incluídos
+
+| Módulo | Tipo de grupo | Rota de configuração |
+|---|---|---|
+| `ldap_departments_sync` | `departments` | `/admin/config/local-modules/ldap-departments-sync` |
+| `ldap_research_groups_sync` | `research_group` | `/admin/config/local-modules/ldap-research-groups-sync` |
+
+## Estrutura
+
+```
+ldap_groups_sync/
+├── src/
+│ ├── GroupAccessRulesService.php # serviço de verificação de acesso
+│ └── Form/
+│ ├── AccessRulesFormBase.php # listagem e remoção de regras
+│ └── AccessRuleFormBase.php # criação/edição de regra (modal)
+├── translations/
+│ └── ldap_groups_sync.pt-br.po
+│
+├── ldap_departments_sync/ # sub-módulo
+└── ldap_research_groups_sync/ # sub-módulo
+```
+
+## Regras de acesso
+
+A infraestrutura de regras de acesso permite configurar, por interface, quais operações (`create`, `update`, `delete`, `view`) cada entidade permite — e a quais membros de grupo com quais papéis.
+
+Cada regra define:
+
+- **Tipo de entidade / bundle** ao qual se aplica
+- **Operações** controladas
+- **Modo**: _Restritivo_ (nega quem não satisfaz) ou _Aditivo_ (só concede, sem negar)
+- **Requisitos de membro**: um ou mais pares (grupo + papéis requeridos)
+- **Condições de campo** opcionais avaliadas sobre a entidade existente (para `update`/`delete`/`view`)
+
+## Como adicionar um novo sub-módulo
+
+1. Crie o diretório do módulo dentro de `ldap_groups_sync/`.
+
+2. Declare `ldap_groups_sync:ldap_groups_sync` como dependência no `.info.yml`.
+
+3. No `.services.yml`, use `GroupAccessRulesService` com o config name como terceiro argumento:
+
+```yaml
+meu_modulo.access_rules:
+ class: Drupal\ldap_groups_sync\GroupAccessRulesService
+ arguments: ['@config.factory', '@entity_type.manager', 'meu_modulo.settings']
+```
+
+4. Crie `src/Form/AccessRulesForm.php` estendendo `AccessRulesFormBase`:
+
+```php
+namespace Drupal\meu_modulo\Form;
+
+use Drupal\ldap_groups_sync\Form\AccessRulesFormBase;
+
+class AccessRulesForm extends AccessRulesFormBase {
+ protected function getConfigName(): string { return 'meu_modulo.settings'; }
+ public function getFormId(): string { return 'meu_modulo_access_rules_form'; }
+ protected function getAccessRuleFormRoute(): string { return 'meu_modulo.access_rule_form'; }
+}
+```
+
+5. Crie `src/Form/AccessRuleForm.php` estendendo `AccessRuleFormBase`:
+
+```php
+namespace Drupal\meu_modulo\Form;
+
+use Drupal\ldap_groups_sync\Form\AccessRuleFormBase;
+
+class AccessRuleForm extends AccessRuleFormBase {
+ protected function getConfigName(): string { return 'meu_modulo.settings'; }
+ public function getFormId(): string { return 'meu_modulo_access_rule_form'; }
+ protected function getAccessRulesRoute(): string { return 'meu_modulo.access_rules'; }
+ protected function getDefaultGroupTypeId(): string { return 'meu_group_type'; }
+}
+```
+
+## Dependências
+
+- `drupal:options`
+- `drupal:telephone`
+- `group:group`
+- `ldap:ldap_servers`
+- `site_tools`
+
+## Licença
+
+GPL-2.0-or-later
+
+## Autor
+
+Desenvolvido para o IME - UNICAMP
diff --git a/ldap_departments_sync/CHANGELOG.md b/ldap_departments_sync/CHANGELOG.md
new file mode 100644
index 0000000..d6a4dd9
--- /dev/null
+++ b/ldap_departments_sync/CHANGELOG.md
@@ -0,0 +1,46 @@
+# Changelog
+
+All notable changes to the LDAP Departments Sync module will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0] - 2025-11-07
+
+### Added
+- Initial stable release of LDAP Departments Sync module
+- LDAP server and query configuration with dynamic dropdowns
+- Hierarchical group organization based on LDAP attributes
+- Dynamic attribute mapping system for flexible field configuration
+- User reference mapping with DN to Drupal User resolution
+- Automatic search attribute detection from LDAP server configuration
+- Comprehensive cron validation to prevent execution with incomplete configurations
+- Administrative interface with management buttons for LDAP queries
+- Extensive logging and error handling throughout the synchronization process
+- Support for both manual and automatic synchronization via cron
+
+### Features
+- **LDAP Integration**: Full integration with `ldap_servers` and `ldap_query` modules
+- **Dynamic Configuration**: AJAX-powered form with real-time updates based on selections
+- **Hierarchical Groups**: Automatic parent-child relationship mapping from LDAP data
+- **User Reference Mapping**: Convert LDAP DNs to Drupal User references automatically
+- **Flexible Mapping**: Support for both simple text and user reference field mappings
+- **Validation**: Comprehensive configuration validation before cron execution
+- **Management Tools**: Direct links to edit LDAP queries
+- **Error Recovery**: Robust error handling with detailed logging for troubleshooting
+
+### Technical Details
+- **Drupal Version**: Compatible with Drupal 11
+- **Dependencies**: group, ldap_servers
+- **Architecture**: Service-based with dependency injection
+- **Configuration**: YAML-based with dynamic form interface
+- **Security**: Defensive coding practices with proper input validation
+
+### Configuration
+- LDAP server selection with status verification
+- Query selection filtered by server with real-time updates
+- Dynamic attribute mapping with type-specific processing
+- Hierarchical organization with parent/child attribute configuration
+- User reference mapping with automatic search attribute detection
+
+[1.0.0]: https://github.com/your-repo/ldap_departments_sync/releases/tag/v1.0.0
\ No newline at end of file
diff --git a/ldap_departments_sync/README.md b/ldap_departments_sync/README.md
new file mode 100644
index 0000000..2c67df1
--- /dev/null
+++ b/ldap_departments_sync/README.md
@@ -0,0 +1,184 @@
+# LDAP Departments Sync
+
+Módulo Drupal para sincronização de departamentos do LDAP com grupos do Drupal.
+
+## Funcionalidades
+
+- Sincronização automática de departamentos do LDAP para grupos no Drupal
+- Mapeamento flexível de atributos LDAP para campos de grupo
+- Sincronização de membros de departamentos baseada em campos de usuário
+- Suporte a hierarquia de departamentos
+- Mapeamento dinâmico de papéis (roles) nos grupos baseado em campos
+- Campos de referência a departamentos reutilizáveis para content types
+
+## Requisitos
+
+- Drupal 10.x ou 11.x
+- Módulo LDAP (ldap_servers, ldap_query)
+- Módulo Group
+- Módulo Entity Reference Views Select (opcional, para widgets avançados)
+- Módulo Telephone (para campo de telefone)
+- Módulo Profile (opcional, para usar field_department em profiles)
+
+## Instalação
+
+1. Coloque o módulo no diretório `modules/custom/ldap_departments_sync`
+2. Habilite o módulo: `drush en ldap_departments_sync -y`
+3. Configure em `/admin/config/ldap/ldap_departments_sync`
+
+### Instalação da Configuração Padrão
+
+O módulo inclui configurações opcionais que podem ser instaladas via interface:
+
+1. Acesse `/admin/config/ldap/ldap_departments_sync`
+2. Na seção "Tipo de Grupo", clique em "Instalar Configuração Padrão"
+3. Isso criará:
+ - Tipo de grupo "Departments"
+ - Campos de departamento (código, sigla, tipo, telefone, sala, e-mail, coordenadores)
+ - Roles do grupo (admin, member, etc.)
+ - Displays de visualização e formulário
+
+## Configuração
+
+### Mapeamento de Atributos LDAP
+
+Configure os mapeamentos entre atributos LDAP e campos de grupo em:
+`/admin/config/ldap/ldap_departments_sync`
+
+Exemplo de configuração padrão:
+- `imeccDepartmentCode` → `field_dept_code`
+- `description` → `label`
+- `cn` → `field_dept_acronym`
+- `imeccDepartmentType` → `field_dept_type`
+- `imeccDepartmentCoord` → `field_dept_coord` (referência a usuário)
+- `imeccDepartmentAssocCoord` → `field_dept_coord_assoc` (referência a usuário)
+
+### Sincronização de Membros
+
+O módulo pode sincronizar automaticamente membros para os grupos baseado em:
+- Matching entre `field_user_dept_code` (usuário) e `field_dept_code` (grupo)
+- Atribuição automática de roles baseado em campos de usuário
+
+### Hierarquia de Departamentos
+
+Para habilitar hierarquia:
+1. Marque "Enable Hierarchy"
+2. Configure:
+ - **Parent Attribute**: atributo LDAP do pai (ex: `departmentNumber`)
+ - **Child Attribute**: atributo LDAP do filho para matching (ex: `imeccDepartmentCode`)
+
+## Adicionando Campos de Referência a Departamentos
+
+O módulo fornece um **handler de seleção customizado** que facilita a criação de campos de referência a departamentos em qualquer entidade (nodes, users, profiles, etc.).
+
+### Como Adicionar um Campo de Departamento
+
+1. Acesse a página de gerenciamento de campos da entidade desejada:
+ - **Content Types**: `Structure > Content types > [Tipo] > Manage fields`
+ - **User**: `Configuration > People > Account settings > Manage fields`
+ - **Profiles**: `Configuration > People > Profile types > [Tipo] > Manage fields`
+
+2. Clique em **"Create a new field"**
+
+3. Em **"Add a new field"**:
+ - Selecione **"Reference"** como categoria
+ - Escolha **"Other..."** (ou "Outro...")
+ - Preencha o **Label** (ex: "Departamento", "Department", "Setor", etc.)
+ - Clique em **"Continue"**
+
+4. Configure o field storage:
+ - **Type of item to reference**: Selecione **"Group"** (Grupo)
+ - Clique em **"Save field settings"**
+
+5. Configure as definições do campo:
+ - **Reference method**: Selecione **"Department selection"**
+ - **Group type**: Automaticamente pré-selecionado como **"Departments"**
+ - **Filter by department type**: (Opcional) Marque para filtrar por tipo
+ - **Allowed department types**: (Opcional) Selecione tipos permitidos:
+ - ☐ Acadêmico
+ - ☐ Administrativo
+ - Configure outras opções conforme necessário:
+ - **Required**: Se o campo é obrigatório
+ - **Help text**: Texto de ajuda
+ - **Default value**: Valor padrão
+ - Clique em **"Save settings"**
+
+### Vantagens desta Abordagem
+
+- ✅ **Nomes customizáveis**: Escolha qualquer machine name para o campo
+- ✅ **Múltiplos campos**: Adicione vários campos de departamento no mesmo bundle com propósitos diferentes
+- ✅ **Filtragem por tipo**: Opção de filtrar apenas departamentos acadêmicos ou administrativos
+- ✅ **Pré-configurado**: O handler "Department selection" já vem configurado com as opções corretas
+- ✅ **Flexível**: Use em qualquer tipo de entidade (nodes, users, profiles, custom entities, etc.)
+
+### Exemplos de Uso
+
+**Múltiplos campos no mesmo content type:**
+```
+Article:
+ - field_primary_department (obrigatório, todos os tipos)
+ - field_secondary_department (opcional, todos os tipos)
+ - field_academic_dept (opcional, apenas acadêmicos)
+```
+
+**Diferentes configurações por bundle:**
+```
+Student Profile:
+ - field_department (obrigatório, apenas acadêmicos)
+
+Staff Profile:
+ - field_department (obrigatório, apenas administrativos)
+```
+
+## Sincronização Manual
+
+Execute via Drush:
+
+```bash
+drush ldap-departments-sync
+```
+
+Ou via interface em `/admin/config/ldap/ldap_departments_sync` clicando em "Sync Now".
+
+## Sincronização Automática (Cron)
+
+O módulo sincroniza automaticamente durante a execução do cron do Drupal.
+
+## Estrutura de Campos do Grupo
+
+Campos padrão criados para o group type "departments":
+
+- `field_dept_code` - Código do departamento (texto, obrigatório, único)
+- `field_dept_acronym` - Sigla/acrônimo
+- `field_dept_type` - Tipo (acadêmico/administrativo)
+- `field_dept_phone` - Telefone
+- `field_dept_room` - Sala
+- `field_dept_mail` - E-mail
+- `field_dept_coord` - Coordenador (referência a usuário)
+- `field_dept_coord_assoc` - Coordenador associado (referência a usuário)
+- `field_parent_group` - Grupo pai (para hierarquia)
+
+## Campos de Usuário
+
+Campos necessários no user entity para sincronização de membros:
+
+- `field_user_dept_code` - Código do departamento do usuário
+- `field_user_department` - Referência ao grupo de departamento
+
+## Desenvolvimento
+
+### Hooks Disponíveis
+
+(Documentação futura)
+
+### API
+
+(Documentação futura)
+
+## Licença
+
+GPL-2.0-or-later
+
+## Autor
+
+Desenvolvido para o IME - UNICAMP
diff --git a/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml b/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml
new file mode 100644
index 0000000..cd747e5
--- /dev/null
+++ b/ldap_departments_sync/config/optional/core.entity_form_display.group.departments.default.yml
@@ -0,0 +1,127 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.departments.field_dept_acronym
+ - field.field.group.departments.field_dept_code
+ - field.field.group.departments.field_dept_coord
+ - field.field.group.departments.field_dept_coord_assoc
+ - field.field.group.departments.field_dept_mail
+ - field.field.group.departments.field_dept_phone
+ - field.field.group.departments.field_dept_room
+ - field.field.group.departments.field_dept_type
+ - field.field.group.departments.field_parent_group
+ - group.type.departments
+ module:
+ - path
+id: group.departments.default
+targetEntityType: group
+bundle: departments
+mode: default
+content:
+ field_dept_acronym:
+ type: string_textfield
+ weight: 123
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_code:
+ type: string_textfield
+ weight: 122
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_coord:
+ type: entity_reference_autocomplete
+ weight: 128
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_coord_assoc:
+ type: entity_reference_autocomplete
+ weight: 129
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_mail:
+ type: email_default
+ weight: 125
+ region: content
+ settings:
+ placeholder: ''
+ size: 60
+ third_party_settings: { }
+ field_dept_phone:
+ type: string_textfield
+ weight: 124
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_room:
+ type: string_textfield
+ weight: 121
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_dept_type:
+ type: options_select
+ weight: 127
+ region: content
+ settings: { }
+ third_party_settings: { }
+ field_parent_group:
+ type: entity_reference_autocomplete
+ weight: 20
+ 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/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml b/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml
new file mode 100644
index 0000000..1387873
--- /dev/null
+++ b/ldap_departments_sync/config/optional/core.entity_view_display.group.departments.default.yml
@@ -0,0 +1,107 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.departments.field_dept_acronym
+ - field.field.group.departments.field_dept_code
+ - field.field.group.departments.field_dept_coord
+ - field.field.group.departments.field_dept_coord_assoc
+ - field.field.group.departments.field_dept_mail
+ - field.field.group.departments.field_dept_phone
+ - field.field.group.departments.field_dept_room
+ - field.field.group.departments.field_dept_type
+ - field.field.group.departments.field_parent_group
+ - group.type.departments
+ module:
+ - options
+id: group.departments.default
+targetEntityType: group
+bundle: departments
+mode: default
+content:
+ field_dept_acronym:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -2
+ region: content
+ field_dept_code:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -3
+ region: content
+ field_dept_coord:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 22
+ region: content
+ field_dept_coord_assoc:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 23
+ region: content
+ field_dept_mail:
+ type: basic_string
+ label: above
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+ field_dept_phone:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -1
+ region: content
+ field_dept_room:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -4
+ region: content
+ field_dept_type:
+ type: list_default
+ label: above
+ settings: { }
+ third_party_settings: { }
+ weight: 21
+ region: content
+ field_parent_group:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 20
+ 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/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml b/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml
new file mode 100644
index 0000000..f77a8a9
--- /dev/null
+++ b/ldap_departments_sync/config/optional/core.entity_view_display.user.user.default.yml
@@ -0,0 +1,68 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.field.user.user.field_user_category
+ - field.field.user.user.field_user_department
+ - field.field.user.user.field_user_dept_code
+ - field.field.user.user.field_user_work_phone
+
+ - field.field.user.user.user_picture
+ - image.style.thumbnail
+ module:
+ - image
+ - telephone
+ - user
+_core:
+ default_config_hash: mZLyuWM9CQx2ZJVqFGSbzgFnHzudVbHBYmdU256A5Wk
+id: user.user.default
+targetEntityType: user
+bundle: user
+mode: default
+content:
+ field_user_category:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: 3
+ region: content
+ field_user_department:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 10
+ region: content
+ field_user_work_phone:
+ type: telephone_link
+ label: above
+ settings:
+ title: ''
+ third_party_settings: { }
+ weight: 4
+ region: content
+ member_for:
+ settings: { }
+ third_party_settings: { }
+ weight: 1
+ region: content
+ user_picture:
+ type: image
+ label: hidden
+ settings:
+ image_link: content
+ image_style: thumbnail
+ image_loading:
+ attribute: lazy
+ third_party_settings: { }
+ weight: 0
+ region: content
+hidden:
+ entity_print_view_epub: true
+ entity_print_view_pdf: true
+ entity_print_view_word_docx: true
+ field_user_dept_code: true
+ langcode: true
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml
new file mode 100644
index 0000000..efc6dc6
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_acronym.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_acronym
+ - group.type.departments
+id: group.departments.field_dept_acronym
+field_name: field_dept_acronym
+entity_type: group
+bundle: departments
+label: Sigla
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml
new file mode 100644
index 0000000..3cdfcbf
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_code.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_code
+ - group.type.departments
+id: group.departments.field_dept_code
+field_name: field_dept_code
+entity_type: group
+bundle: departments
+label: Código
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml
new file mode 100644
index 0000000..e3f7d9c
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_coord
+ - group.type.departments
+id: group.departments.field_dept_coord
+field_name: field_dept_coord
+entity_type: group
+bundle: departments
+label: Coordenador
+description: ''
+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/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml
new file mode 100644
index 0000000..5cf8297
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_coord_assoc.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_coord_assoc
+ - group.type.departments
+id: group.departments.field_dept_coord_assoc
+field_name: field_dept_coord_assoc
+entity_type: group
+bundle: departments
+label: 'Coordenador Associado'
+description: ''
+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/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml
new file mode 100644
index 0000000..7396a1e
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_mail.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_mail
+ - group.type.departments
+id: group.departments.field_dept_mail
+field_name: field_dept_mail
+entity_type: group
+bundle: departments
+label: Email
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: email
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml
new file mode 100644
index 0000000..340544e
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_phone.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_phone
+ - group.type.departments
+id: group.departments.field_dept_phone
+field_name: field_dept_phone
+entity_type: group
+bundle: departments
+label: Telefone
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml
new file mode 100644
index 0000000..a1a25c0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_room.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_room
+ - group.type.departments
+id: group.departments.field_dept_room
+field_name: field_dept_room
+entity_type: group
+bundle: departments
+label: Sala
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml
new file mode 100644
index 0000000..c786c54
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_dept_type.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_dept_type
+ - group.type.departments
+ module:
+ - options
+id: group.departments.field_dept_type
+field_name: field_dept_type
+entity_type: group
+bundle: departments
+label: 'Tipo'
+description: 'Tipo do departamento (Administrativo ou Acadêmico)'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: list_string
diff --git a/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml b/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml
new file mode 100644
index 0000000..ff63a4d
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.group.departments.field_parent_group.yml
@@ -0,0 +1,27 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_parent_group
+ - group.type.departments
+id: group.departments.field_parent_group
+field_name: field_parent_group
+entity_type: group
+bundle: departments
+label: 'Órgão superior'
+description: 'Grupo pai na hierarquia de departamentos'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:group'
+ handler_settings:
+ target_bundles:
+ departments: departments
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ auto_create_bundle: ''
+field_type: entity_reference
diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml
new file mode 100644
index 0000000..d38621d
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_category.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_category
+ module:
+ - user
+id: user.user.field_user_category
+field_name: field_user_category
+entity_type: user
+bundle: user
+label: Category
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml
new file mode 100644
index 0000000..bcbcd4d
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_department.yml
@@ -0,0 +1,24 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_department
+ module:
+ - user
+id: user.user.field_user_department
+field_name: field_user_department
+entity_type: user
+bundle: user
+label: Departamento
+description: 'Departamento do usuário sincronizado do LDAP'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: views
+ handler_settings:
+ view:
+ view_name: departments
+ display_name: entity_reference_1
+field_type: entity_reference
diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml
new file mode 100644
index 0000000..b49c8ab
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_dept_code.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_dept_code
+ module:
+ - user
+id: user.user.field_user_dept_code
+field_name: field_user_dept_code
+entity_type: user
+bundle: user
+label: 'Department Code'
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml b/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml
new file mode 100644
index 0000000..3aa05c0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.field.user.user.field_user_work_phone.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_work_phone
+ module:
+ - telephone
+ - user
+id: user.user.field_user_work_phone
+field_name: field_user_work_phone
+entity_type: user
+bundle: user
+label: 'Work Phone'
+description: 'Work phone number, populated from LDAP.'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: telephone
diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml
new file mode 100644
index 0000000..192f7dc
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_acronym.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_dept_acronym
+field_name: field_dept_acronym
+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/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml
new file mode 100644
index 0000000..003bf42
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_code.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_dept_code
+field_name: field_dept_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/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml
new file mode 100644
index 0000000..aec2d39
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_dept_coord
+field_name: field_dept_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/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml
new file mode 100644
index 0000000..957fac0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_coord_assoc.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_dept_coord_assoc
+field_name: field_dept_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/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml
new file mode 100644
index 0000000..85b6b97
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_mail.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_dept_mail
+field_name: field_dept_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/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml
new file mode 100644
index 0000000..73651c5
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_phone.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_dept_phone
+field_name: field_dept_phone
+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/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml
new file mode 100644
index 0000000..931ab0a
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_room.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_dept_room
+field_name: field_dept_room
+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/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml b/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml
new file mode 100644
index 0000000..cb9cf82
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_dept_type.yml
@@ -0,0 +1,26 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - options
+id: group.field_dept_type
+field_name: field_dept_type
+entity_type: group
+type: list_string
+settings:
+ allowed_values:
+ -
+ value: academico
+ label: Acadêmico
+ -
+ value: administrativo
+ label: Administrativo
+ allowed_values_function: ''
+module: options
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml b/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml
new file mode 100644
index 0000000..9745cda
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.group.field_parent_group.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_parent_group
+field_name: field_parent_group
+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/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml
new file mode 100644
index 0000000..dcfc5ea
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_category.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - user
+id: user.field_user_category
+field_name: field_user_category
+entity_type: user
+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/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml
new file mode 100644
index 0000000..5d044df
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_department.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: user.field_user_department
+field_name: field_user_department
+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/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml
new file mode 100644
index 0000000..1348ae0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_dept_code.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - user
+id: user.field_user_dept_code
+field_name: field_user_dept_code
+entity_type: user
+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/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml b/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml
new file mode 100644
index 0000000..7d58684
--- /dev/null
+++ b/ldap_departments_sync/config/optional/field.storage.user.field_user_work_phone.yml
@@ -0,0 +1,18 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - telephone
+ - user
+id: user.field_user_work_phone
+field_name: field_user_work_phone
+entity_type: user
+type: telephone
+settings: { }
+module: telephone
+locked: false
+cardinality: 1
+translatable: true
+indexes: { }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin.yml b/ldap_departments_sync/config/optional/group.role.departments-admin.yml
new file mode 100644
index 0000000..0122561
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-admin.yml
@@ -0,0 +1,45 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+id: departments-admin
+label: Admin
+weight: 100
+admin: true
+scope: individual
+global_role: null
+group_type: departments
+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
+ - create group_node:webform entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete any group_node:webform entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - delete own group_node:webform entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update any group_node:webform entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - update own group_node:webform entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view group_node:webform entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view own unpublished group_node:webform entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
+ - view unpublished group_node:webform entity
diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml b/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml
new file mode 100644
index 0000000..3070fe0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-admin_in.yml
@@ -0,0 +1,46 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+ - user.role.administrator
+id: departments-admin_in
+label: Administrador
+weight: 102
+admin: true
+scope: insider
+global_role: administrator
+group_type: departments
+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
+ - create group_node:webform entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete any group_node:webform entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - delete own group_node:webform entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update any group_node:webform entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - update own group_node:webform entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view group_node:webform entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view own unpublished group_node:webform entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
+ - view unpublished group_node:webform entity
diff --git a/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml b/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml
new file mode 100644
index 0000000..b8b1318
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-admin_out.yml
@@ -0,0 +1,46 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+ - user.role.administrator
+id: departments-admin_out
+label: Administrador
+weight: 101
+admin: true
+scope: outsider
+global_role: administrator
+group_type: departments
+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
+ - create group_node:webform entity
+ - delete any group_node:article entity
+ - delete any group_node:page entity
+ - delete any group_node:webform entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - delete own group_node:webform entity
+ - update any group_node:article entity
+ - update any group_node:page entity
+ - update any group_node:webform entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - update own group_node:webform entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view group_node:webform entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view own unpublished group_node:webform entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
+ - view unpublished group_node:webform entity
diff --git a/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml b/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml
new file mode 100644
index 0000000..20c7c45
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-anonymous.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+ - user.role.anonymous
+id: departments-anonymous
+label: Anônimo
+weight: -102
+admin: false
+scope: outsider
+global_role: anonymous
+group_type: departments
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/ldap_departments_sync/config/optional/group.role.departments-member.yml b/ldap_departments_sync/config/optional/group.role.departments-member.yml
new file mode 100644
index 0000000..26310e1
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-member.yml
@@ -0,0 +1,36 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+ - user.role.authenticated
+id: departments-member
+label: Member
+weight: -100
+admin: false
+scope: insider
+global_role: authenticated
+group_type: departments
+permissions:
+ - view group
+ - access group_media overview
+ - access group_node overview
+ - create group_node:article entity
+ - create group_node:page entity
+ - create group_node:webform entity
+ - delete own group_node:article entity
+ - delete own group_node:page entity
+ - delete own group_node:webform entity
+ - update any group_node:webform entity
+ - update own group_node:article entity
+ - update own group_node:page entity
+ - update own group_node:webform entity
+ - view group_node:article entity
+ - view group_node:page entity
+ - view group_node:webform entity
+ - view own unpublished group_node:article entity
+ - view own unpublished group_node:page entity
+ - view own unpublished group_node:webform entity
+ - view unpublished group_node:article entity
+ - view unpublished group_node:page entity
+ - view unpublished group_node:webform entity
diff --git a/ldap_departments_sync/config/optional/group.role.departments-outsider.yml b/ldap_departments_sync/config/optional/group.role.departments-outsider.yml
new file mode 100644
index 0000000..fc87df0
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.role.departments-outsider.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.departments
+ - user.role.authenticated
+id: departments-outsider
+label: Outsider
+weight: -101
+admin: false
+scope: outsider
+global_role: authenticated
+group_type: departments
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/ldap_departments_sync/config/optional/group.type.departments.yml b/ldap_departments_sync/config/optional/group.type.departments.yml
new file mode 100644
index 0000000..ef2603d
--- /dev/null
+++ b/ldap_departments_sync/config/optional/group.type.departments.yml
@@ -0,0 +1,10 @@
+langcode: pt-br
+status: true
+dependencies: { }
+id: departments
+label: Departamento
+description: ''
+new_revision: true
+creator_membership: true
+creator_wizard: true
+creator_roles: { }
diff --git a/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml b/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml
new file mode 100644
index 0000000..b6d463d
--- /dev/null
+++ b/ldap_departments_sync/config/translations/pt-br/field.field.user.user.field_user_work_phone.yml
@@ -0,0 +1,2 @@
+label: 'Telefone Profissional'
+description: 'Número de telefone profissional, populado via LDAP.'
diff --git a/ldap_departments_sync/css/role-mapping.css b/ldap_departments_sync/css/role-mapping.css
new file mode 100644
index 0000000..f5f17b1
--- /dev/null
+++ b/ldap_departments_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/ldap_departments_sync/ldap_departments_sync.info.yml b/ldap_departments_sync/ldap_departments_sync.info.yml
new file mode 100644
index 0000000..5294447
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.info.yml
@@ -0,0 +1,13 @@
+name: LDAP Departments Sync
+type: module
+description: 'Sincroniza departamentos do servidor LDAP com grupos via cron'
+version: '1.1.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/ldap_departments_sync/ldap_departments_sync.install b/ldap_departments_sync/ldap_departments_sync.install
new file mode 100644
index 0000000..f78e04a
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.install
@@ -0,0 +1,106 @@
+addWarning(t('Please ensure your group type has the required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail, and field_parent_group (for hierarchy).'));
+}
+
+/**
+ * Renames field_user_phone_number to field_user_work_phone.
+ */
+function ldap_departments_sync_update_10001() {
+ // 1. Create new field storage.
+ if (!FieldStorageConfig::loadByName('user', 'field_user_work_phone')) {
+ FieldStorageConfig::create([
+ 'field_name' => 'field_user_work_phone',
+ 'entity_type' => 'user',
+ 'type' => 'telephone',
+ 'cardinality' => 1,
+ 'translatable' => TRUE,
+ ])->save();
+ }
+
+ // 2. Create new field instance.
+ if (!FieldConfig::loadByName('user', 'user', 'field_user_work_phone')) {
+ FieldConfig::create([
+ 'field_name' => 'field_user_work_phone',
+ 'entity_type' => 'user',
+ 'bundle' => 'user',
+ 'label' => 'Work Phone',
+ 'description' => 'Work phone number, populated from LDAP.',
+ 'required' => FALSE,
+ 'translatable' => FALSE,
+ ])->save();
+ }
+
+ // 3. Copy data directly via SQL — efficient and side-effect-free.
+ $database = \Drupal::database();
+ $schema = $database->schema();
+ foreach (['user__', 'user_revision__'] as $prefix) {
+ $old_table = $prefix . 'field_user_phone_number';
+ $new_table = $prefix . 'field_user_work_phone';
+ if ($schema->tableExists($old_table) && $schema->tableExists($new_table)) {
+ $database->query("
+ INSERT INTO {{$new_table}}
+ (bundle, deleted, entity_id, revision_id, langcode, delta, field_user_work_phone_value)
+ SELECT bundle, deleted, entity_id, revision_id, langcode, delta, field_user_phone_number_value
+ FROM {{$old_table}}
+ WHERE deleted = 0
+ ");
+ }
+ }
+
+ // 4. Update form display.
+ $form_display = EntityFormDisplay::load('user.user.default');
+ if ($form_display) {
+ $component = $form_display->getComponent('field_user_phone_number');
+ if ($component) {
+ $form_display->removeComponent('field_user_phone_number');
+ $form_display->setComponent('field_user_work_phone', $component);
+ $form_display->save();
+ }
+ }
+
+ // 5. Update view display.
+ $view_display = EntityViewDisplay::load('user.user.default');
+ if ($view_display) {
+ $component = $view_display->getComponent('field_user_phone_number');
+ if ($component) {
+ $view_display->removeComponent('field_user_phone_number');
+ $view_display->setComponent('field_user_work_phone', $component);
+ $view_display->save();
+ }
+ }
+
+ // 6. Delete old field instance then storage.
+ if ($old_config = FieldConfig::loadByName('user', 'user', 'field_user_phone_number')) {
+ $old_config->delete();
+ }
+ if ($old_storage = FieldStorageConfig::loadByName('user', 'field_user_phone_number')) {
+ $old_storage->delete();
+ }
+
+ return t('Renamed field_user_phone_number to field_user_work_phone.');
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function ldap_departments_sync_uninstall() {
+ // Remove configurações
+ \Drupal::configFactory()->getEditable('ldap_departments_sync.settings')->delete();
+
+ \Drupal::messenger()->addStatus(t('Módulo LDAP Departments Sync desinstalado.'));
+}
diff --git a/ldap_departments_sync/ldap_departments_sync.libraries.yml b/ldap_departments_sync/ldap_departments_sync.libraries.yml
new file mode 100644
index 0000000..da24478
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.libraries.yml
@@ -0,0 +1,5 @@
+role_mapping_styles:
+ version: 1.x
+ css:
+ theme:
+ css/role-mapping.css: {}
diff --git a/ldap_departments_sync/ldap_departments_sync.links.menu.yml b/ldap_departments_sync/ldap_departments_sync.links.menu.yml
new file mode 100644
index 0000000..83f4e0c
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.links.menu.yml
@@ -0,0 +1,6 @@
+ldap_departments_sync.config:
+ title: 'LDAP Departments Sync'
+ description: 'Configurar sincronização de departamentos do LDAP'
+ route_name: ldap_departments_sync.config
+ parent: site_tools.admin_config
+ weight: 5
diff --git a/ldap_departments_sync/ldap_departments_sync.links.task.yml b/ldap_departments_sync/ldap_departments_sync.links.task.yml
new file mode 100644
index 0000000..a8a4334
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.links.task.yml
@@ -0,0 +1,11 @@
+ldap_departments_sync.tab.config:
+ title: 'Configuration'
+ route_name: ldap_departments_sync.config
+ base_route: ldap_departments_sync.config
+ weight: 0
+
+ldap_departments_sync.tab.access_rules:
+ title: 'Access Rules'
+ route_name: ldap_departments_sync.access_rules
+ base_route: ldap_departments_sync.config
+ weight: 10
diff --git a/ldap_departments_sync/ldap_departments_sync.module b/ldap_departments_sync/ldap_departments_sync.module
new file mode 100644
index 0000000..e1a0656
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.module
@@ -0,0 +1,291 @@
+' . t('This module synchronizes departments from an LDAP server to groups through cron.') . '
';
+ }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function ldap_departments_sync_cron() {
+ // Check if module is properly configured
+ $config = \Drupal::config('ldap_departments_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_departments_sync')->warning('Synchronization cancelled: LDAP server "@server_id" not found or inactive. Configure at /admin/config/local-modules/ldap-departments-sync', [
+ '@server_id' => $ldap_server_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_departments_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_departments_sync')->warning('Synchronization cancelled: LDAP query "@query_id" not found or inactive. Configure at /admin/config/local-modules/ldap-departments-sync', [
+ '@query_id' => $ldap_query_id,
+ ]);
+ return;
+ }
+ }
+ else {
+ \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: ldap_query module not available.');
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_departments_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_departments_sync')->warning('Synchronization cancelled: group type "@type_id" not found. Configure at /admin/config/local-modules/ldap-departments-sync', [
+ '@type_id' => $group_type_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_departments_sync')->warning('Synchronization cancelled: error checking group type: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Get LDAP synchronization service
+ $ldap_sync = \Drupal::service('ldap_departments_sync.sync');
+
+ // Execute departments synchronization
+ try {
+ $ldap_sync->syncDepartments();
+ \Drupal::logger('ldap_departments_sync')->info('Departments synchronization executed successfully.');
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_departments_sync')->error('Error in departments 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_departments_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_departments_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_departments_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_departments_sync.access_rules')
+ ->checkCreateAccess($account, $entity_type_id, $entity_bundle ?? '');
+}
+
+/**
+ * Implements hook_entity_field_access().
+ *
+ * Nega acesso de edição aos campos gerenciados pelo LDAP sync em formulários
+ * e APIs (REST, JSON:API). Saves programáticos não são afetados.
+ *
+ * Campos protegidos no usuário:
+ * - field_user_category: categoria do usuário, populado pelo ldap_user.
+ * - field_user_dept_code: código do departamento, populado pelo ldap_user.
+ * - field_user_department: referência ao grupo, populado pelo nosso sync.
+ * Proteger este campo evita o erro de validação "Esta entidade não pode
+ * ser referenciada" que ocorre quando o widget tenta validar o grupo
+ * referenciado contra o acesso do usuário atual.
+ * - field_user_work_phone: telefone de trabalho, populado pelo ldap_user.
+ */
+function ldap_departments_sync_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
+ $protected_user_fields = [
+ 'field_user_category',
+ 'field_user_dept_code',
+ 'field_user_department',
+ 'field_user_work_phone',
+ ];
+
+ 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().
+ *
+ * Protege os campos gerenciados pelo LDAP sync de alterações programáticas
+ * não autorizadas em usuários existentes. Permite:
+ * - Saves durante sync LDAP autorizado (LdapDepartmentsSync::$syncing TRUE)
+ * - Criação de novos usuários (provisionamento inicial via ldap_user)
+ * - Salvas por usuários com a permissão 'edit ldap managed user fields'
+ */
+function ldap_departments_sync_user_presave(\Drupal\user\UserInterface $user) {
+ // Permite durante qualquer sync LDAP autorizado.
+ if (LdapDepartmentsSync::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 fields')) {
+ return;
+ }
+
+ $original = $user->original;
+ if ($original === NULL) {
+ return;
+ }
+
+ // Campos gerenciados pelo LDAP que não podem ser alterados externamente.
+ $protected_fields = [
+ 'field_user_category',
+ 'field_user_dept_code',
+ 'field_user_department',
+ 'field_user_work_phone',
+ ];
+
+ 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_departments_sync')->warning(
+ 'Tentativa não autorizada de alterar @field do usuário @username foi bloqueada. Use a sincronização LDAP para atualizar este campo.',
+ [
+ '@field' => $field_name,
+ '@username' => $user->getAccountName(),
+ ]
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_predelete() for user entities.
+ *
+ * Cleans up user references in department groups when a user is deleted.
+ */
+function ldap_departments_sync_user_predelete(\Drupal\user\UserInterface $user) {
+ $config = \Drupal::config('ldap_departments_sync.settings');
+ $group_type_id = $config->get('group_type_id') ?: 'departments';
+
+ // Fields that may contain user references
+ $user_reference_fields = [
+ 'field_dept_coord',
+ 'field_dept_coord_assoc',
+ ];
+
+ try {
+ $group_storage = \Drupal::entityTypeManager()->getStorage('group');
+ $user_id = $user->id();
+
+ // Find groups that reference this user
+ foreach ($user_reference_fields as $field_name) {
+ $groups = $group_storage->loadByProperties([
+ 'type' => $group_type_id,
+ $field_name => $user_id,
+ ]);
+
+ foreach ($groups as $group) {
+ if ($group->hasField($field_name)) {
+ $group->set($field_name, NULL);
+ $group->save();
+ \Drupal::logger('ldap_departments_sync')->info('Removed user @username reference from @field in group @group', [
+ '@username' => $user->getAccountName(),
+ '@field' => $field_name,
+ '@group' => $group->label(),
+ ]);
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_departments_sync')->error('Error cleaning up user references: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ }
+}
diff --git a/ldap_departments_sync/ldap_departments_sync.permissions.yml b/ldap_departments_sync/ldap_departments_sync.permissions.yml
new file mode 100644
index 0000000..784654f
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.permissions.yml
@@ -0,0 +1,9 @@
+administer ldap departments sync:
+ title: 'Administrar LDAP Departments Sync'
+ description: 'Configurar sincronização de departamentos do LDAP'
+ restrict access: true
+
+edit ldap managed user fields:
+ title: 'Editar campos gerenciados pelo LDAP no usuário'
+ description: 'Permite editar manualmente campos como field_user_dept_code que são normalmente gerenciados pelo LDAP. Use apenas em emergências.'
+ restrict access: true
\ No newline at end of file
diff --git a/ldap_departments_sync/ldap_departments_sync.routing.yml b/ldap_departments_sync/ldap_departments_sync.routing.yml
new file mode 100644
index 0000000..f208140
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.routing.yml
@@ -0,0 +1,25 @@
+ldap_departments_sync.config:
+ path: '/admin/config/local-modules/ldap-departments-sync'
+ defaults:
+ _form: '\Drupal\ldap_departments_sync\Form\LdapDepartmentsSyncConfigForm'
+ _title: 'LDAP Departments Sync'
+ requirements:
+ _permission: 'administer ldap departments sync'
+
+ldap_departments_sync.access_rules:
+ path: '/admin/config/local-modules/ldap-departments-sync/access-rules'
+ defaults:
+ _form: '\Drupal\ldap_departments_sync\Form\AccessRulesForm'
+ _title: 'Access Rules'
+ requirements:
+ _permission: 'administer ldap departments sync'
+
+ldap_departments_sync.access_rule_form:
+ path: '/admin/config/local-modules/ldap-departments-sync/access-rule/{rule_index}'
+ defaults:
+ _form: '\Drupal\ldap_departments_sync\Form\AccessRuleForm'
+ _title: 'Access Rule'
+ rule_index: 'new'
+ requirements:
+ _permission: 'administer ldap departments sync'
+ rule_index: 'new|\d+'
diff --git a/ldap_departments_sync/ldap_departments_sync.services.yml b/ldap_departments_sync/ldap_departments_sync.services.yml
new file mode 100644
index 0000000..2495aa8
--- /dev/null
+++ b/ldap_departments_sync/ldap_departments_sync.services.yml
@@ -0,0 +1,8 @@
+services:
+ ldap_departments_sync.sync:
+ class: Drupal\ldap_departments_sync\LdapDepartmentsSync
+ arguments: ['@entity_type.manager', '@config.factory', '@logger.factory', '@ldap.bridge']
+
+ ldap_departments_sync.access_rules:
+ class: Drupal\ldap_groups_sync\GroupAccessRulesService
+ arguments: ['@config.factory', '@entity_type.manager', 'ldap_departments_sync.settings']
diff --git a/ldap_departments_sync/src/Controller/LocalModulesController.php b/ldap_departments_sync/src/Controller/LocalModulesController.php
new file mode 100644
index 0000000..e21f05a
--- /dev/null
+++ b/ldap_departments_sync/src/Controller/LocalModulesController.php
@@ -0,0 +1,22 @@
+t('Local Modules');
+ }
+
+}
\ No newline at end of file
diff --git a/ldap_departments_sync/src/Form/AccessRuleForm.php b/ldap_departments_sync/src/Form/AccessRuleForm.php
new file mode 100644
index 0000000..0013c0f
--- /dev/null
+++ b/ldap_departments_sync/src/Form/AccessRuleForm.php
@@ -0,0 +1,40 @@
+entityTypeManager = $entity_type_manager;
+ $this->ldapDepartmentsSync = $ldap_departments_sync;
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('ldap_departments_sync.sync'),
+ $container->get('messenger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['ldap_departments_sync.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'ldap_departments_sync_config_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $config = $this->config('ldap_departments_sync.settings');
+
+ // Group Type
+ $form['group_type'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Group Type'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ // Get list of group types
+ $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 departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail'),
+ '#options' => $group_types,
+ '#empty_option' => $this->t('- Select a group type -'),
+ '#default_value' => $config->get('group_type_id') ?: 'departments',
+ '#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' => '
',
+ ];
+
+ // Verificar se o tipo de grupo "departments" existe
+ $departments_exists = isset($group_types['departments']);
+
+ if (empty($group_types) || !$departments_exists) {
+ $form['group_type']['setup_notice'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('The default "departments" 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 "departments" with all required fields
+ - User fields for department 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') . ' ',
+ ];
+
+ // Button to edit selected group type
+ $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'departments';
+ 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,
+ ];
+
+ // Get list of LDAP servers
+ $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 departments 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 departments.'),
+ '#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') . ' ',
+ ];
+
+ // Button to edit selected 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') . '',
+ ];
+ }
+ }
+
+ // Hierarchy
+ $form['hierarchy'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Hierarchy'),
+ '#description' => $this->t('Configure whether departments should be organized hierarchically based on LDAP attributes.'),
+ '#collapsible' => FALSE,
+ ];
+
+ $form['hierarchy']['enable_hierarchy'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable hierarchy'),
+ '#description' => $this->t('When enabled, terms will be organized hierarchically based on the specified parent and child attributes.'),
+ '#default_value' => $config->get('enable_hierarchy') ?: FALSE,
+ '#ajax' => [
+ 'callback' => '::updateHierarchyFields',
+ 'wrapper' => 'hierarchy-fields-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [
+ ['group_type_id'],
+ ['ldap_server_id'],
+ ['ldap_query_id'],
+ ['enable_hierarchy'],
+ ],
+ ];
+
+ $form['hierarchy']['hierarchy_fields'] = [
+ '#type' => 'container',
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ $enable_hierarchy = $form_state->getValue('enable_hierarchy') ?: $config->get('enable_hierarchy') ?: FALSE;
+ if ($enable_hierarchy) {
+ // Get attributes from selected query for hierarchy
+ $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);
+
+ $form['hierarchy']['hierarchy_fields']['parent_attribute'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Parent Attribute'),
+ '#description' => $this->t('LDAP attribute that contains the parent department code (e.g. departmentNumber).'),
+ '#options' => $ldap_attributes,
+ '#empty_option' => $this->t('- Select an attribute -'),
+ '#default_value' => $config->get('parent_attribute'),
+ '#required' => FALSE,
+ ];
+
+ $form['hierarchy']['hierarchy_fields']['child_attribute'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Child Attribute'),
+ '#description' => $this->t('LDAP attribute that contains the department\'s own code (e.g. imeccDepartmentCode).'),
+ '#options' => $ldap_attributes,
+ '#empty_option' => $this->t('- Select an attribute -'),
+ '#default_value' => $config->get('child_attribute'),
+ '#required' => FALSE,
+ ];
+
+ $form['hierarchy']['hierarchy_fields']['hierarchy_note'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('All elements with the same child value will be nested under the corresponding parent recursively.') .
+ '
',
+ ];
+ }
+
+ // 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 (or "Authentication name attribute" as fallback) configured in the selected LDAP server.'),
+ '#collapsible' => FALSE,
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+
+ // Get fields from selected group type
+ $selected_group_type = $form_state->getValue('group_type_id') ?: $config->get('group_type_id') ?: 'departments';
+ $entity_fields = $this->getGroupTypeFields($selected_group_type);
+
+ // Get attributes from selected query
+ $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);
+
+ // Check if mappings were just reset by AJAX callback
+ $mappings_reset = $form_state->get('mappings_reset');
+
+ // Get existing mappings - prioritize form_state during rebuild
+ $form_state_mappings = $form_state->getValue('mappings');
+
+ // If mappings were just reset, use them directly
+ if ($mappings_reset && !empty($form_state_mappings)) {
+ $existing_mappings = $form_state_mappings;
+ \Drupal::logger('ldap_departments_sync')->debug('Using reset mappings: @mappings', [
+ '@mappings' => print_r($existing_mappings, TRUE),
+ ]);
+ }
+ elseif (!empty($form_state_mappings)) {
+ // Filter mappings to only include valid fields for current entity type
+ $existing_mappings = [];
+ $valid_fields = array_keys($entity_fields);
+
+ foreach ($form_state_mappings as $mapping) {
+ // Only include mapping if field exists in current entity type
+ if (empty($mapping['field']) || in_array($mapping['field'], $valid_fields)) {
+ $existing_mappings[] = $mapping;
+ }
+ }
+
+ // If no valid mappings remain, use defaults
+ if (empty($existing_mappings)) {
+ $existing_mappings = $this->getDefaultMappings();
+ }
+ } else {
+ $config_mappings = $config->get('attribute_mappings');
+
+ // Validate config mappings against current entity fields
+ 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();
+ }
+ }
+
+ // Mappings table
+ $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.'),
+ ];
+
+ // Add existing rows
+ $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'
+ ];
+
+ // Validate that the field exists in available options
+ $field_default = isset($mapping['field']) ? $mapping['field'] : '';
+ if (!empty($field_default) && !isset($entity_fields[$field_default])) {
+ $field_default = '';
+ }
+
+ // Validate that the attribute exists in available options
+ $attribute_default = isset($mapping['attribute']) ? $mapping['attribute'] : '';
+ if (!empty($attribute_default) && !isset($ldap_attributes[$attribute_default])) {
+ $attribute_default = '';
+ }
+
+ // Build field element
+ $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 were reset via AJAX, force the value
+ 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)'),
+ ],
+ '#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,
+ ];
+ }
+
+ // Clear the mappings_reset flag after using it for all fields
+ if ($mappings_reset) {
+ $form_state->set('mappings_reset', FALSE);
+ }
+
+ // Action buttons
+ $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' => [],
+ ];
+
+ // User synchronization
+ $form['sync_users'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable user department field synchronization'),
+ '#description' => $this->t('When enabled, the field_user_department on each user will be updated to reference the matching department group based on field_user_dept_code.'),
+ '#default_value' => $config->get('sync_users') ?: FALSE,
+ ];
+
+ // 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. Otherwise, all users will be assigned the default role.'),
+ '#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' => '
',
+ ];
+
+ // Check if role mapping is enabled from form state or config
+ $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) {
+ // Get available group roles
+ $group_roles = $this->getGroupRoles($selected_group_type);
+
+ // Get LDAP attributes for role mapping (reuse same logic as attribute mapping)
+ // Note: $ldap_attributes was already defined earlier in the form (line 345)
+ // But we need to ensure it's available here too
+ $role_ldap_attributes = $ldap_attributes ?? [];
+ $user_fields = $this->getUserFields();
+
+ // Get existing role mappings
+ $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 ?: [];
+ }
+
+ // Se não há mapeamentos configurados, adiciona o mapeamento padrão de member
+ if (empty($existing_role_mappings)) {
+ $existing_role_mappings = [
+ [
+ 'group_role' => $selected_group_type . '-member',
+ 'source' => 'group_field_match',
+ 'source_field' => 'field_user_dept_code',
+ 'group_field' => 'field_dept_code',
+ 'values' => '',
+ ],
+ ];
+ }
+
+ // Role mappings table
+ $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_departments_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);
+
+ // Get group fields for "Group Field Match" type
+ $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' => '',
+ ];
+
+ // Get current source value to filter fields
+ $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',
+ ],
+ ];
+
+ // Third column: User Field/LDAP Attribute (changes based on source)
+ if ($current_source === 'group_field_match') {
+ // For group_field_match, show user fields only
+ $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 {
+ // For fixed value match, show fields based on source type
+ 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']],
+ ];
+ }
+
+ // Fourth column: Group Field (for group_field_match) or Values (for fixed value match)
+ if ($current_source === 'group_field_match') {
+ // Show group field selector
+ $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 {
+ // Show values textarea
+ $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,
+ ];
+ }
+
+ // Action buttons for role mapping
+ $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_departments'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Synchronize Departments'),
+ '#submit' => ['::syncDepartments'],
+ '#button_type' => 'danger',
+ ];
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * Gets list of available LDAP servers.
+ *
+ * @return array
+ * Array of LDAP servers [id => label].
+ */
+ 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.
+ *
+ * @param string $server_id
+ * LDAP server ID.
+ * @param string $query_id
+ * LDAP query ID.
+ *
+ * @return array
+ * Array of attributes [attribute => attribute].
+ */
+ protected function getQueryAttributes($server_id, $query_id) {
+ $attributes = [];
+
+ if (empty($query_id) || !$this->entityTypeManager->hasDefinition('ldap_query_entity')) {
+ // Return default attributes if no query selected
+ return [
+ 'imeccDepartmentCode' => 'imeccDepartmentCode',
+ 'description' => 'description',
+ 'cn' => 'cn',
+ 'imeccDepartmentType' => 'imeccDepartmentType',
+ 'telephoneNumber' => 'telephoneNumber',
+ 'roomNumber' => 'roomNumber',
+ 'mail' => 'mail',
+ ];
+ }
+
+ 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 failed to get attributes from query, return defaults
+ if (empty($attributes)) {
+ $attributes = [
+ 'imeccDepartmentCode' => 'imeccDepartmentCode',
+ 'description' => 'description',
+ 'cn' => 'cn',
+ 'imeccDepartmentType' => 'imeccDepartmentType',
+ 'telephoneNumber' => 'telephoneNumber',
+ 'roomNumber' => 'roomNumber',
+ 'mail' => 'mail',
+ ];
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Returns default mappings.
+ *
+ * @return array
+ * Array of default mappings.
+ */
+ protected function getDefaultMappings() {
+ return [
+ ['field' => 'label', 'attribute' => 'description', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_code', 'attribute' => 'imeccDepartmentCode', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_acronym', 'attribute' => 'cn', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_type', 'attribute' => 'imeccDepartmentType', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_phone', 'attribute' => 'telephoneNumber', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_room', 'attribute' => 'roomNumber', 'mapping_type' => 'simple'],
+ ['field' => 'field_dept_mail', 'attribute' => 'mail', 'mapping_type' => 'simple'],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ // Validate attribute mappings
+ $mapping_values = $form_state->getValue('mappings');
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $index => $mapping_data) {
+ // Skip rows marked for removal
+ if (!empty($mapping_data['remove'])) {
+ continue;
+ }
+
+ // Check if there are partially filled fields
+ $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_departments_sync.settings');
+
+ // Save basic configurations
+ $config->set('ldap_server_id', $form_state->getValue('ldap_server_id'))
+ ->set('ldap_query_id', $form_state->getValue('ldap_query_id'))
+ ->set('enable_hierarchy', $form_state->getValue('enable_hierarchy'))
+ ->set('parent_attribute', $form_state->getValue('parent_attribute'))
+ ->set('child_attribute', $form_state->getValue('child_attribute'))
+ ->set('group_type_id', $form_state->getValue('group_type_id'))
+ ->set('sync_users', $form_state->getValue('sync_users'));
+
+ // Process attribute mappings
+ $mappings = [];
+
+ // Process attribute mappings
+ $mapping_values = $form_state->getValue('mappings');
+
+ if (!empty($mapping_values)) {
+ foreach ($mapping_values as $mapping_data) {
+ // Skip only mappings marked for removal
+ if (!empty($mapping_data['remove'])) {
+ continue;
+ }
+
+ // Only skip if BOTH field AND attribute are empty (completely empty row)
+ if (empty($mapping_data['field']) && empty($mapping_data['attribute'])) {
+ continue;
+ }
+
+ $mapping = [
+ 'field' => $mapping_data['field'],
+ 'attribute' => $mapping_data['attribute'],
+ 'mapping_type' => $mapping_data['mapping_type'] ?? 'simple',
+ ];
+
+ $mappings[] = $mapping;
+ }
+ }
+
+ $config->set('attribute_mappings', $mappings);
+
+ // Process role mappings
+ $role_mappings = [];
+ $role_mapping_values = $form_state->getValue('role_mappings');
+
+ if (!empty($role_mapping_values)) {
+ foreach ($role_mapping_values as $role_mapping_data) {
+ // Skip mappings marked for removal
+ if (!empty($role_mapping_data['remove'])) {
+ continue;
+ }
+
+ // Skip completely empty rows
+ if (empty($role_mapping_data['group_role']) && empty($role_mapping_data['source_field'])) {
+ continue;
+ }
+
+ $source = $role_mapping_data['source'];
+
+ // Process values based on source type
+ if ($source === 'group_field_match') {
+ // For group_field_match, 'values' field contains the group field name
+ $role_mapping = [
+ 'group_role' => $role_mapping_data['group_role'],
+ 'source' => $source,
+ 'source_field' => $role_mapping_data['source_field'],
+ 'group_field' => $role_mapping_data['values'], // group field is stored in values field
+ 'values' => [], // No fixed values for group field match
+ ];
+ } else {
+ // For fixed value match (ldap_attribute or user_field)
+ $values = $role_mapping_data['values'];
+ if (is_string($values)) {
+ $values = array_map('trim', explode(',', $values));
+ $values = array_filter($values); // Remove empty values
+ }
+
+ $role_mapping = [
+ 'group_role' => $role_mapping_data['group_role'],
+ 'source' => $source,
+ 'source_field' => $role_mapping_data['source_field'],
+ 'group_field' => '', // No group field for fixed value match
+ '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('Selecione um servidor LDAP para testar.'));
+ return;
+ }
+
+ try {
+ // Teste simples de conexão
+ $server_storage = $this->entityTypeManager->getStorage('ldap_server');
+ $server = $server_storage->load($server_id);
+
+ if ($server && $server->get('status')) {
+ $this->messenger->addStatus($this->t('Servidor LDAP "@server" está configurado e ativo.', [
+ '@server' => $server->label(),
+ ]));
+ }
+ else {
+ $this->messenger->addError($this->t('Servidor LDAP não encontrado ou inativo.'));
+ }
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Erro ao testar conexão: @message', ['@message' => $e->getMessage()]));
+ }
+ }
+
+ /**
+ * Submit handler para sincronizar departamentos.
+ */
+ public function syncDepartments(array &$form, FormStateInterface $form_state) {
+ if ($form_state->getErrors()) {
+ $this->messenger->addError($this->t('Corrija os erros no formulário antes de executar a sincronização.'));
+ return;
+ }
+
+ try {
+ $this->ldapDepartmentsSync->syncDepartments();
+ $this->messenger->addStatus($this->t('Sincronização executada com sucesso. Verifique os logs para detalhes.'));
+ }
+ catch (\Exception $e) {
+ $this->messenger->addError($this->t('Erro durante sincronização: @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) {
+ // Obter valores atuais do formulário ou da configuração
+ $current_mappings = $form_state->getValue('mappings');
+
+ // Se não há valores no form_state, carregar da configuração
+ if (empty($current_mappings)) {
+ $config = $this->config('ldap_departments_sync.settings');
+ $current_mappings = $config->get('attribute_mappings') ?: $this->getDefaultMappings();
+ }
+
+ // Converter para formato esperado se necessário
+ $processed_mappings = [];
+ foreach ($current_mappings as $mapping) {
+ $processed_mappings[] = [
+ 'field' => $mapping['field'] ?? '',
+ 'attribute' => $mapping['attribute'] ?? '',
+ 'mapping_type' => $mapping['mapping_type'] ?? 'simple',
+ 'remove' => FALSE,
+ ];
+ }
+
+ // Adicionar nova linha vazia
+ $processed_mappings[] = [
+ 'field' => '',
+ 'attribute' => '',
+ 'mapping_type' => 'simple',
+ 'remove' => FALSE,
+ ];
+
+ // Atualizar form_state
+ $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) {
+ // Manter apenas mapeamentos que não estão marcados para remoção
+ if (empty($mapping_data['remove'])) {
+ $new_mappings[] = $mapping_data;
+ }
+ }
+ }
+
+ // Atualizar form_state com os mapeamentos restantes
+ $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_departments_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,
+ ];
+ }
+
+ // Add new empty row
+ $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.
+ *
+ * @param string $group_type_id
+ * Group type ID.
+ *
+ * @return array
+ * Array of roles [role_id => role_label].
+ */
+ 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();
+
+ // Skip internal-only roles (anonymous and outsider)
+ if (strpos($role_id, '-anonymous') !== FALSE || strpos($role_id, '-outsider') !== FALSE) {
+ continue;
+ }
+
+ // Include scope in the label to distinguish between roles with same label
+ // The role ID format is typically: {group_type}-{role_name}_{scope}
+ // Example: departments-admin_in or departments-admin_out
+ $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.
+ *
+ * @return array
+ * Array of user fields [field_name => label].
+ */
+ 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) {
+ // Skip system fields
+ 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;
+ }
+
+ /**
+ * Callback AJAX para atualizar campos de hierarquia quando switch é alterado.
+ */
+ public function updateHierarchyFields(array &$form, FormStateInterface $form_state) {
+ return $form['hierarchy']['hierarchy_fields'];
+ }
+
+ /**
+ * Obtém lista de queries LDAP para um servidor específico.
+ *
+ * @param string $server_id
+ * ID do servidor LDAP.
+ *
+ * @return array
+ * Array de queries LDAP [id => label].
+ */
+ protected function getLdapQueriesForServer($server_id) {
+ $queries = [];
+
+ if (empty($server_id)) {
+ return $queries;
+ }
+
+ // Verifica se o entity type ldap_query_entity existe
+ if (!$this->entityTypeManager->hasDefinition('ldap_query_entity')) {
+ $this->messenger->addWarning($this->t('Módulo ldap_query não está disponível. As queries LDAP não podem ser carregadas.'));
+ 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('Erro ao carregar queries LDAP: @message', ['@message' => $e->getMessage()]));
+ }
+
+ return $queries;
+ }
+
+ /**
+ * Gets list of available group types.
+ *
+ * @return array
+ * Array of group types [id => label].
+ */
+ 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.
+ *
+ * @param string $group_type_id
+ * Group type ID.
+ *
+ * @return array
+ * Array of fields [field_name => label].
+ */
+ protected function getGroupTypeFields($group_type_id) {
+ $fields = [
+ 'label' => $this->t('Label (group title)'),
+ ];
+
+ if (empty($group_type_id)) {
+ return $fields;
+ }
+
+ try {
+ // Check if group type exists
+ $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) {
+ // Skip system fields
+ 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_departments_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;
+
+ // Ordem de importação
+ $import_order = [
+ // Field storages primeiro
+ 'field.storage.*.yml',
+ // Tipo de grupo
+ 'group.type.*.yml',
+ // Roles do grupo
+ 'group.role.*.yml',
+ // Field instances
+ 'field.field.*.yml',
+ // Displays
+ 'core.entity_*.yml',
+ ];
+
+ foreach ($import_order as $pattern) {
+ $files = glob($config_path . '/' . $pattern);
+ foreach ($files as $file) {
+ $config_name = basename($file, '.yml');
+
+ // Verificar se já existe
+ $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;
+ }
+
+ // Importar
+ $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) {
+ // Limpar caches necessários
+ \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();
+
+ // Limpar cache de configuração e rotas
+ \Drupal::service('config.factory')->clearStaticCache();
+ \Drupal::service('router.builder')->rebuild();
+
+ $this->messenger->addStatus($this->t('Successfully installed @count configuration items. Group type "departments" and all required fields have been created. Please reload the page to see the changes.', [
+ '@count' => $files_imported,
+ ]));
+ } else {
+ $this->messenger->addWarning($this->t('All configurations already exist. Nothing was imported.'));
+ }
+
+ } catch (\Exception $e) {
+ $this->messenger->addError($this->t('Error installing default configuration: @message', ['@message' => $e->getMessage()]));
+ \Drupal::logger('ldap_departments_sync')->error('Error installing configuration: @message', ['@message' => $e->getMessage()]);
+ }
+
+ // Redirecionar usando a URL atual para forçar reload completo
+ $current_path = \Drupal::service('path.current')->getPath();
+ $form_state->setRedirectUrl(\Drupal\Core\Url::fromUserInput($current_path));
+ }
+
+}
\ No newline at end of file
diff --git a/ldap_departments_sync/src/LdapDepartmentsSync.php b/ldap_departments_sync/src/LdapDepartmentsSync.php
new file mode 100644
index 0000000..40a7e28
--- /dev/null
+++ b/ldap_departments_sync/src/LdapDepartmentsSync.php
@@ -0,0 +1,1531 @@
+entityTypeManager = $entity_type_manager;
+ $this->configFactory = $config_factory;
+ $this->logger = $logger_factory->get('ldap_departments_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 departments from LDAP to groups.
+ */
+ public function syncDepartments() {
+ $this->logger->info('Starting departments synchronization using configured LDAP query.');
+
+ // Verifica configuração antes de iniciar
+ $config = $this->configFactory->get('ldap_departments_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 {
+ // Conecta ao LDAP e obtém departments usando query configurada
+ $departments = $this->fetchDepartmentsUsingQuery();
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro durante fetchDepartmentsUsingQuery: @message', ['@message' => $e->getMessage()]);
+ throw $e;
+ }
+
+ if (empty($departments)) {
+ $this->logger->warning('Nenhum department encontrado no LDAP.');
+ return;
+ }
+
+ $this->logger->info('Iniciando sincronização de @count departments', ['@count' => count($departments)]);
+
+ // Obtém todos as entidades existentes
+ $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_dept_code') && !$entity->get('field_dept_code')->isEmpty()) {
+ $code = $entity->get('field_dept_code')->value;
+ $existing_codes[$code] = $entity;
+ }
+ }
+
+ $created = 0;
+ $updated = 0;
+
+ // Processa cada department do LDAP
+ foreach ($departments as $dept_data) {
+ $code = $dept_data['code'];
+ $name = $dept_data['name'];
+ $acronym = $dept_data['acronym'];
+ $type = $dept_data['type'];
+ $phone = $dept_data['phone'];
+ $room = $dept_data['room'];
+ $mail = $dept_data['mail'];
+
+ // Campos extras (incluindo referências de usuário)
+ $extra_fields = [];
+ foreach ($dept_data as $field => $value) {
+ if (!in_array($field, ['code', 'name', 'acronym', 'type', 'phone', 'room', 'mail'])) {
+ $extra_fields[$field] = $value;
+ }
+ }
+
+ $this->logger->info('Processando department - Código: @code, Nome: @name, Sigla: @acronym', [
+ '@code' => $code,
+ '@name' => $name,
+ '@acronym' => $acronym,
+ ]);
+
+ 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();
+
+ // Set name/label field
+ if ($name_field === 'label') {
+ $entity->set('label', $name);
+ }
+ else {
+ $entity->setName($name);
+ }
+
+ if ($entity->hasField('field_dept_acronym')) {
+ $entity->set('field_dept_acronym', $acronym);
+ }
+ if ($entity->hasField('field_dept_type')) {
+ $entity->set('field_dept_type', $type);
+ }
+ if ($entity->hasField('field_dept_phone')) {
+ $entity->set('field_dept_phone', $phone);
+ }
+ if ($entity->hasField('field_dept_room')) {
+ $entity->set('field_dept_room', $room);
+ }
+ if ($entity->hasField('field_dept_mail')) {
+ $entity->set('field_dept_mail', $mail);
+ }
+
+ // Atualizar campos extras
+ 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_dept_code' => $code,
+ 'field_dept_acronym' => $acronym,
+ 'field_dept_type' => $type,
+ 'field_dept_phone' => $phone,
+ 'field_dept_room' => $room,
+ 'field_dept_mail' => $mail,
+ 'uid' => 1, // Admin user as owner
+ ];
+
+ // Adicionar campos extras
+ 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 departamento criado ou atualizado.');
+ }
+
+ // Aplica hierarquização se habilitada
+ $this->applyHierarchy();
+
+ // Atualiza campo field_user_department dos usuários locais
+ $this->syncUserDepartments();
+
+ // Sincroniza membros dos grupos
+ $this->syncGroupMembers();
+ }
+
+
+ /**
+ * Busca entidade de departamento pelo código.
+ *
+ * @param string $dept_code
+ * Código do departamento.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface|null
+ * Entidade (termo de taxonomia ou grupo) ou NULL se não encontrado.
+ */
+ public function getDepartmentByCode($dept_code) {
+ if (empty($dept_code)) {
+ return NULL;
+ }
+
+ $bundle_id = $this->getBundleId();
+ $bundle_field = $this->getBundleField();
+ $entity_storage = $this->getEntityStorage();
+
+ $entities = $entity_storage->loadByProperties([
+ $bundle_field => $bundle_id,
+ 'field_dept_code' => $dept_code,
+ ]);
+
+ return !empty($entities) ? reset($entities) : NULL;
+ }
+
+ /**
+ * Busca departments usando a query LDAP configurada.
+ *
+ * @return array
+ * Array de departments com código e descrição.
+ */
+ protected function fetchDepartmentsUsingQuery() {
+ $departments = [];
+
+ $this->logger->info('Iniciando fetchDepartmentsUsingQuery...');
+
+ // Verifica se o storage de queries está disponível
+ 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 $departments;
+ }
+
+ $this->logger->info('Storage de queries LDAP está disponível.');
+
+ try {
+ // Obtém ID da query configurada
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ $query_id = $config->get('ldap_query_id') ?: 'department_sync';
+
+ $this->logger->info('Tentando carregar query LDAP: @query_id', ['@query_id' => $query_id]);
+
+ // Carrega a query LDAP configurada
+ $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-departments-sync', [
+ '@query_id' => $query_id,
+ ]);
+ return $departments;
+ }
+
+ $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 $departments;
+ }
+
+ $this->logger->info('Usando query LDAP configurada: @query_id (@label)', [
+ '@query_id' => $query_id,
+ '@label' => $query_entity->label(),
+ ]);
+
+ $this->logger->info('Executando query LDAP...');
+
+ // Verifica métodos disponíveis na query entity
+ $methods = get_class_methods($query_entity);
+ $this->logger->info('Métodos disponíveis na query: @methods', ['@methods' => implode(', ', $methods)]);
+
+ // Usa os parâmetros da query com LdapBridge
+ try {
+ // Obtém parâmetros da query
+ $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',
+ ]);
+
+ // Configura o LdapBridge com o servidor da query
+ $this->ldapBridge->setServerById($server_id);
+
+ // Conecta ao servidor
+ if (!$this->ldapBridge->bind()) {
+ throw new \Exception('Falha ao conectar ao servidor LDAP');
+ }
+
+ $this->logger->info('Conectado ao servidor LDAP com sucesso');
+
+ // Executa busca para cada Base DN
+ $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');
+
+ // Prepara atributos (pode ser array ou string)
+ $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)]);
+
+ // Converte para array se necessário
+ 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(),
+ ]);
+ // Continua para próximo Base DN se houver erro
+ }
+ }
+
+ $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 $departments;
+ }
+
+ $this->logger->info('Query LDAP retornou @count resultados', ['@count' => count($results)]);
+
+ // Processa os resultados da query usando mapeamentos dinâmicos
+ $departments = $this->processLdapResults($results);
+
+ $this->logger->info('Processados @count departments via query LDAP', ['@count' => count($departments)]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]);
+ }
+
+ return $departments;
+ }
+
+ /**
+ * Sincroniza departamentos dos usuários locais do Drupal.
+ *
+ * Este método atualiza o campo field_user_department dos usuários locais
+ * (criados pelo ldap_user) fazendo match entre field_user_dept_code e
+ * field_dept_code do departamento.
+ */
+ public function syncUserDepartments() {
+ // Obtém configurações
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+
+ // Verifica se a sincronização de usuários está habilitada
+ if (!$config->get('sync_users')) {
+ $this->logger->info('User synchronization is disabled.');
+ return;
+ }
+
+ $this->logger->info('Starting user department field synchronization.');
+
+ // Carrega todos os usuários ativos que têm código de departamento
+ $user_storage = $this->entityTypeManager->getStorage('user');
+ $query = $user_storage->getQuery()
+ ->condition('status', 1)
+ ->condition('uid', 0, '>') // Exclui usuário anônimo
+ ->exists('field_user_dept_code')
+ ->accessCheck(FALSE);
+
+ $uids = $query->execute();
+
+ if (empty($uids)) {
+ $this->logger->warning('No users with field_user_dept_code found.');
+ return;
+ }
+
+ $users = $user_storage->loadMultiple($uids);
+ $this->logger->info('Processing @count users for department field update', ['@count' => count($users)]);
+
+ // Carrega todos os departamentos uma vez
+ $dept_storage = $this->getEntityStorage();
+ $bundle_field = $this->getBundleField();
+ $bundle_id = $this->getBundleId();
+ $departments = $dept_storage->loadByProperties([$bundle_field => $bundle_id]);
+
+ // Cria um mapa de dept_code => department para busca rápida
+ $depts_by_code = [];
+ foreach ($departments as $dept) {
+ if ($dept->hasField('field_dept_code') && !$dept->get('field_dept_code')->isEmpty()) {
+ $code = $dept->get('field_dept_code')->value;
+ $depts_by_code[$code] = $dept;
+ }
+ }
+
+ $this->logger->info('Found @count departments with codes', ['@count' => count($depts_by_code)]);
+
+ $updated = 0;
+ $skipped = 0;
+
+ // Sinaliza que os saves a seguir fazem parte de um sync LDAP autorizado,
+ // de modo que hook_user_presave() não bloqueie as alterações.
+ self::$syncing = TRUE;
+ try {
+ foreach ($users as $user) {
+ $username = $user->getAccountName();
+
+ // Obtém o código de departamento do usuário
+ if (!$user->hasField('field_user_dept_code') || $user->get('field_user_dept_code')->isEmpty()) {
+ $skipped++;
+ continue;
+ }
+
+ $user_dept_code = $user->get('field_user_dept_code')->value;
+
+ // Busca o departamento correspondente pelo código
+ if (!isset($depts_by_code[$user_dept_code])) {
+ $this->logger->warning('No department found with code "@code" for user @username. field_user_department will not be updated.', [
+ '@code' => $user_dept_code,
+ '@username' => $username,
+ ]);
+ $skipped++;
+ continue;
+ }
+
+ $department = $depts_by_code[$user_dept_code];
+
+ // Atualiza campo field_user_department do usuário
+ if ($user->hasField('field_user_department')) {
+ $current_dept = $user->get('field_user_department')->target_id;
+ if ($current_dept != $department->id()) {
+ $user->set('field_user_department', $department->id());
+ $user->save();
+ $updated++;
+ } else {
+ $skipped++;
+ }
+ } else {
+ $this->logger->warning('User @username does not have field_user_department', [
+ '@username' => $username,
+ ]);
+ $skipped++;
+ }
+ }
+ }
+ finally {
+ self::$syncing = FALSE;
+ }
+
+ $this->logger->info('User department field synchronization completed. Updated: @updated, Skipped: @skipped', [
+ '@updated' => $updated,
+ '@skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * Sincroniza membros dos grupos baseado no código de departamento dos usuários.
+ *
+ * Este método adiciona usuários do Drupal como membros dos grupos fazendo
+ * match entre user.field_user_dept_code e group.field_dept_code.
+ */
+ public function syncGroupMembers() {
+ $this->logger->info('Starting group membership synchronization.');
+
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ $role_mapping_enabled = $config->get('role_mapping_enabled') ?? FALSE;
+ $role_mappings = $config->get('role_mappings') ?? [];
+
+ // Se role mapping não está habilitado, não faz sincronização
+ 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;
+ }
+
+ // Carrega todos os usuários ativos
+ $user_storage = $this->entityTypeManager->getStorage('user');
+ $query = $user_storage->getQuery()
+ ->condition('status', 1)
+ ->condition('uid', 0, '>') // Exclui usuário anônimo
+ ->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)]);
+
+ // Carrega todos os grupos
+ $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;
+
+ // Para cada usuário, verifica em quais grupos ele deve estar
+ foreach ($users as $user) {
+ try {
+ $username = $user->getAccountName();
+
+ // Determina em quais grupos e com quais roles o usuário deve estar
+ $expected_memberships = []; // [group_id => role_id]
+
+ foreach ($groups as $group) {
+ $group_role = $this->determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings);
+
+ // Se determinou um role, o usuário deve estar neste grupo
+ if ($group_role !== NULL) {
+ $expected_memberships[$group->id()] = $group_role;
+ }
+ }
+
+ if (!empty($expected_memberships)) {
+ $matched++;
+ }
+
+ // Processa as memberships esperadas
+ foreach ($expected_memberships as $group_id => $expected_role) {
+ $group = $groups[$group_id];
+ $membership = $group->getMember($user);
+
+ if ($membership) {
+ // Usuário já é membro, verifica se precisa atualizar o papel
+ $current_roles = $membership->getRoles();
+ $current_role_ids = array_map(function($role) {
+ return $role->id();
+ }, $current_roles);
+
+ // Se o papel esperado não está entre os papéis atuais, atualiza
+ if (!in_array($expected_role, $current_role_ids)) {
+ try {
+ // Remove papéis antigos (exceto 'member' base e o novo papel)
+ foreach ($current_role_ids as $role_id) {
+ if ($role_id !== 'member' && $role_id !== $expected_role) {
+ $membership->removeRole($role_id);
+ }
+ }
+ // Adiciona novo papel (se não for apenas 'member')
+ 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 {
+ // Usuário não é membro, adiciona
+ try {
+ $values = [];
+ // Adiciona role se não for o member padrão
+ 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++;
+ }
+ }
+ }
+
+ // Remove usuário de grupos onde ele não deveria estar mais
+ 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,
+ ]);
+ }
+
+ /**
+ * Determina o papel (role) de um usuário em um grupo baseado nos mapeamentos.
+ *
+ * @param \Drupal\user\UserInterface $user
+ * O usuário para verificar.
+ * @param \Drupal\group\Entity\GroupInterface $group
+ * O grupo onde o usuário será adicionado.
+ * @param bool $role_mapping_enabled
+ * Se o mapeamento de papéis está habilitado.
+ * @param array $role_mappings
+ * Array de mapeamentos de papéis.
+ *
+ * @return string|null
+ * O ID do papel a ser atribuído ao usuário, ou NULL se não houver match.
+ */
+ protected function determineUserGroupRole($user, $group, $role_mapping_enabled, $role_mappings) {
+ // Se mapeamento de papéis não está habilitado, retorna NULL
+ if (!$role_mapping_enabled || empty($role_mappings)) {
+ return NULL;
+ }
+
+ // Itera pelos mapeamentos na ordem configurada
+ 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;
+
+ // Obtém o valor do usuário baseado na fonte
+ if ($source === 'user_field') {
+ // Busca em campo do usuário do Drupal
+ if ($user->hasField($source_field) && !$user->get($source_field)->isEmpty()) {
+ $field = $user->get($source_field);
+ // Trata diferentes tipos de campo
+ $field_type = $field->getFieldDefinition()->getType();
+ if (in_array($field_type, ['entity_reference', 'entity_reference_revisions'])) {
+ // Para referências de entidade, pega o target_id
+ $user_value = $field->target_id;
+ } else {
+ // Para campos simples, pega o value
+ $user_value = $field->value;
+ }
+ }
+ } elseif ($source === 'ldap_attribute') {
+ // Para atributos LDAP, precisa buscar do LDAP
+ $config = $this->configFactory->get('ldap_departments_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') {
+ // Para group_field_match, compara campo do usuário com campo do grupo
+ 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);
+
+ // Obtém valores
+ $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;
+ }
+
+ // Compara valores (case-insensitive)
+ if ($user_value !== NULL && $group_value !== NULL &&
+ strcasecmp(trim($user_value), trim($group_value)) === 0) {
+ return $group_role;
+ }
+ }
+ continue; // Não há valores fixos para comparar, então pula para próximo mapping
+ }
+
+ // Verifica se o valor do usuário corresponde a algum dos valores fixos do mapeamento
+ if ($user_value !== NULL && !empty($values)) {
+ foreach ($values as $mapping_value) {
+ if (strcasecmp(trim($user_value), trim($mapping_value)) === 0) {
+ return $group_role;
+ }
+ }
+ }
+ }
+
+ // Se nenhum mapeamento corresponder, retorna NULL (usuário não deve estar neste grupo)
+ return NULL;
+ }
+
+ /**
+ * Busca um atributo LDAP específico para um usuário.
+ *
+ * @param \Drupal\user\UserInterface $user
+ * O usuário.
+ * @param string $attribute
+ * O atributo LDAP a buscar.
+ * @param \Drupal\Core\Config\ImmutableConfig $config
+ * Configuração do módulo.
+ *
+ * @return mixed|null
+ * O valor do atributo ou NULL se não encontrado.
+ */
+ protected function fetchUserLdapAttribute($user, $attribute, $config) {
+ try {
+ // Obtém configurações LDAP
+ $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)';
+
+ // Valida configurações mínimas
+ if (empty($host) || empty($base_dn)) {
+ return NULL;
+ }
+
+ // Conecta ao servidor LDAP
+ $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);
+
+ // Faz bind
+ 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;
+ }
+
+ // Busca o usuário
+ $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);
+
+ // Retorna o valor do atributo se encontrado
+ 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;
+ }
+ }
+
+ /**
+ * Busca usuários do servidor LDAP.
+ *
+ * @param \Drupal\Core\Config\ImmutableConfig $config
+ * Configuração do módulo.
+ *
+ * @return array
+ * Array de usuários com username e código do departamento.
+ */
+ protected function fetchUsersFromLdap($config) {
+ $users = [];
+
+ // Obtém configurações LDAP
+ $ldap_host = $config->get('ldap_host') ?: 'ldap://localhost';
+ $ldap_port = $config->get('ldap_port') ?: 389;
+ $users_base_dn = $config->get('users_base_dn') ?: 'ou=People,dc=example,dc=com';
+ $ldap_bind_dn = $config->get('ldap_bind_dn') ?: '';
+ $ldap_bind_password = $config->get('ldap_bind_password') ?: '';
+ $users_filter = $config->get('users_filter') ?: '(objectClass=person)';
+ $users_department_attribute = $config->get('users_department_attribute') ?: 'departmentNumber';
+
+ // Valida configurações essenciais
+ if (empty($ldap_host) || empty($users_base_dn)) {
+ $this->logger->error('Configurações LDAP para usuários incompletas. Host: @host, Base DN: @base_dn', [
+ '@host' => $ldap_host,
+ '@base_dn' => $users_base_dn,
+ ]);
+ return $users;
+ }
+
+ // Verifica se a extensão LDAP está disponível
+ if (!function_exists('ldap_connect')) {
+ $this->logger->error('Extensão PHP LDAP não está instalada.');
+ return $users;
+ }
+
+ // Conecta ao servidor LDAP (reutiliza lógica existente)
+ $ldap_url = $ldap_host;
+ if (!parse_url($ldap_host, PHP_URL_PORT) && $ldap_port != 389) {
+ $scheme = parse_url($ldap_host, PHP_URL_SCHEME) ?: 'ldap';
+ $host = parse_url($ldap_host, PHP_URL_HOST) ?: str_replace(['ldap://', 'ldaps://'], '', $ldap_host);
+ $ldap_url = $scheme . '://' . $host . ':' . $ldap_port;
+ }
+
+ $this->logger->info('Conectando ao LDAP para usuários: @url', ['@url' => $ldap_url]);
+
+ $ldap_conn = ldap_connect($ldap_url);
+
+ if (!$ldap_conn) {
+ $this->logger->error('Não foi possível criar conexão LDAP para usuários: @url', ['@url' => $ldap_url]);
+ return $users;
+ }
+
+ // Define opções LDAP
+ ldap_set_option($ldap_conn, LDAP_OPT_PROTOCOL_VERSION, 3);
+ ldap_set_option($ldap_conn, LDAP_OPT_REFERRALS, 0);
+ ldap_set_option($ldap_conn, LDAP_OPT_NETWORK_TIMEOUT, 10);
+
+ // Autentica no LDAP
+ if ($ldap_bind_dn && $ldap_bind_password) {
+ $ldap_bind = @ldap_bind($ldap_conn, $ldap_bind_dn, $ldap_bind_password);
+ }
+ else {
+ $ldap_bind = @ldap_bind($ldap_conn);
+ }
+
+ if (!$ldap_bind) {
+ $error = ldap_error($ldap_conn);
+ $errno = ldap_errno($ldap_conn);
+ $this->logger->error('Falha na autenticação LDAP para usuários: @error (Código: @errno)', [
+ '@error' => $error,
+ '@errno' => $errno,
+ ]);
+ ldap_close($ldap_conn);
+ return $users;
+ }
+
+ // Executa busca LDAP
+ $this->logger->info('Executando busca LDAP para usuários - Base DN: @base_dn, Filtro: @filter', [
+ '@base_dn' => $users_base_dn,
+ '@filter' => $users_filter,
+ ]);
+
+ // Define atributos específicos para retornar
+ $attributes = [
+ 'uid',
+ 'cn',
+ $users_department_attribute,
+ 'dn'
+ ];
+
+ $search_result = @ldap_search($ldap_conn, $users_base_dn, $users_filter, $attributes);
+
+ if (!$search_result) {
+ $error = ldap_error($ldap_conn);
+ $errno = ldap_errno($ldap_conn);
+ $this->logger->error('Erro na busca LDAP para usuários: @error (Código: @errno)', [
+ '@error' => $error,
+ '@errno' => $errno,
+ ]);
+ ldap_close($ldap_conn);
+ return $users;
+ }
+
+ // Obtém entradas
+ $entries = ldap_get_entries($ldap_conn, $search_result);
+ $this->logger->info('Encontrados @count usuários no LDAP', ['@count' => $entries['count']]);
+
+ // Processa resultados
+ for ($i = 0; $i < $entries['count']; $i++) {
+ $entry = $entries[$i];
+
+ // Busca case-insensitive dos atributos
+ $username = '';
+ $department_code = '';
+
+ foreach ($entry as $attr_name => $attr_value) {
+ if (strcasecmp($attr_name, 'uid') === 0 && isset($attr_value[0])) {
+ $username = trim($attr_value[0]);
+ }
+ elseif (strcasecmp($attr_name, $users_department_attribute) === 0 && isset($attr_value[0])) {
+ $department_code = trim($attr_value[0]);
+ }
+ }
+
+ if (!empty($username) && !empty($department_code)) {
+ $users[] = [
+ 'username' => $username,
+ 'department_code' => $department_code,
+ ];
+ $this->logger->debug('Usuário encontrado - Username: @username, Dept Code: @dept', [
+ '@username' => $username,
+ '@dept' => $department_code,
+ ]);
+ }
+ }
+
+ // Fecha conexão
+ ldap_close($ldap_conn);
+
+ $this->logger->info('Processados @count usuários do LDAP', ['@count' => count($users)]);
+ return $users;
+ }
+
+ /**
+ * Processes LDAP results using configured attribute mappings.
+ *
+ * @param array $results
+ * Array of LDAP results.
+ *
+ * @return array
+ * Array of processed departments.
+ */
+ protected function processLdapResults(array $results) {
+ $departments = [];
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ $attribute_mappings = $config->get('attribute_mappings') ?: [];
+
+ // Get the code mapping to use as identifier
+ $code_mapping = null;
+ foreach ($attribute_mappings as $mapping) {
+ if ($mapping['field'] === 'field_dept_code') {
+ $code_mapping = $mapping;
+ break;
+ }
+ }
+
+ if (!$code_mapping) {
+ $this->logger->error('No field_dept_code mapping found. Cannot process results.');
+ return $departments;
+ }
+
+ $this->logger->debug('Processing @count LDAP results with @mappings mappings', [
+ '@count' => count($results),
+ '@mappings' => count($attribute_mappings),
+ ]);
+
+ foreach ($results as $entry) {
+ $dept_data = [];
+
+ // Process each attribute mapping
+ foreach ($attribute_mappings as $mapping) {
+ $field = $mapping['field'];
+ $attribute = $mapping['attribute'];
+ $mapping_type = $mapping['mapping_type'] ?? 'simple';
+
+ // Get value from LDAP entry (Entry object)
+ $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;
+ }
+ }
+
+ // Process based on mapping type
+ if ($mapping_type === 'user_reference') {
+ if (!empty($value)) {
+ // For user references, extract username from DN if needed
+ $username = $value;
+ if (stripos($value, 'uid=') !== FALSE) {
+ preg_match('/uid=([^,]+)/i', $value, $matches);
+ $username = $matches[1] ?? $value;
+ }
+
+ // Convert username to Drupal user ID
+ $user_id = $this->getUserIdByUsername($username);
+
+ if ($user_id) {
+ $dept_data[$field] = $user_id;
+ } else {
+ // User not found - this is normal if user doesn't exist in Drupal yet
+ // Log as debug instead of warning since this is expected behavior
+ $this->logger->debug('User @username not found in Drupal for field @field - reference will be empty', [
+ '@username' => $username,
+ '@field' => $field,
+ ]);
+ $dept_data[$field] = null;
+ }
+ } else {
+ $dept_data[$field] = null;
+ }
+ } else {
+ $dept_data[$field] = $value;
+ }
+ }
+
+ // Use code as array key for backwards compatibility
+ $code = $dept_data['field_dept_code'] ?? null;
+ if ($code) {
+ // Map field names to legacy keys
+ $result = [
+ 'code' => $code,
+ 'name' => $dept_data['label'] ?? $dept_data['name'] ?? '',
+ 'acronym' => $dept_data['field_dept_acronym'] ?? '',
+ 'type' => $dept_data['field_dept_type'] ?? '',
+ 'phone' => $dept_data['field_dept_phone'] ?? '',
+ 'room' => $dept_data['field_dept_room'] ?? '',
+ 'mail' => $dept_data['field_dept_mail'] ?? '',
+ ];
+
+ // Include only extra mapped fields (not already covered above)
+ $basic_dept_fields = ['field_dept_code', 'label', 'name', 'field_dept_acronym', 'field_dept_type', 'field_dept_phone', 'field_dept_room', 'field_dept_mail'];
+ foreach ($dept_data as $key => $value) {
+ if (!in_array($key, $basic_dept_fields)) {
+ $result[$key] = $value;
+ }
+ }
+
+ $departments[] = $result;
+ }
+ }
+
+ return $departments;
+ }
+
+ /**
+ * Gets Drupal user ID by username.
+ *
+ * @param string $username
+ * The username to search for.
+ *
+ * @return int|null
+ * The user ID if found, NULL otherwise.
+ */
+ 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;
+ }
+
+ /**
+ * Applies hierarchy to departments based on configuration.
+ */
+ protected function applyHierarchy() {
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+
+ // Check if hierarchy is enabled
+ if (!$config->get('enable_hierarchy')) {
+ $this->logger->info('Hierarchy is disabled. Skipping hierarchy application.');
+ return;
+ }
+
+ $parent_attribute = $config->get('parent_attribute');
+ $child_attribute = $config->get('child_attribute');
+
+ if (empty($parent_attribute) || empty($child_attribute)) {
+ $this->logger->warning('Parent or child attribute not configured. Skipping hierarchy application.');
+ return;
+ }
+
+ $this->logger->info('Applying hierarchy for groups using parent attribute: @parent and child attribute: @child', [
+ '@parent' => $parent_attribute,
+ '@child' => $child_attribute,
+ ]);
+
+ // Fetch raw LDAP entries to get hierarchy attributes
+ $ldap_entries = $this->fetchRawLdapEntries();
+ if (empty($ldap_entries)) {
+ $this->logger->warning('No LDAP entries found. Cannot apply hierarchy.');
+ return;
+ }
+
+ // Build hierarchy map from LDAP data
+ $hierarchy_map = $this->buildHierarchyMap($ldap_entries, $parent_attribute, $child_attribute);
+
+ $this->applyGroupHierarchy($hierarchy_map);
+ }
+
+ /**
+ * Fetches raw LDAP Entry objects from the query.
+ *
+ * @return array
+ * Array of Entry objects from LDAP.
+ */
+ protected function fetchRawLdapEntries() {
+ $entries = [];
+
+ // Verifica se o storage de queries está disponível
+ if (!$this->ldapQueryStorage) {
+ $this->logger->error('Storage de queries LDAP não está disponível.');
+ return $entries;
+ }
+
+ try {
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ $query_id = $config->get('ldap_query_id') ?: 'department_sync';
+
+ $query_entity = $this->ldapQueryStorage->load($query_id);
+
+ if (!$query_entity || !$query_entity->get('status')) {
+ $this->logger->error('Query LDAP não está disponível ou desabilitada.');
+ return $entries;
+ }
+
+ // Obtém parâmetros da query
+ $server_id = $query_entity->getServerId();
+ $base_dns = $query_entity->getProcessedBaseDns();
+ $filter = $query_entity->getFilter();
+ $attributes = $query_entity->getProcessedAttributes();
+
+ // Configura o LdapBridge
+ $this->ldapBridge->setServerById($server_id);
+
+ if (!$this->ldapBridge->bind()) {
+ throw new \Exception('Falha ao conectar ao servidor LDAP');
+ }
+
+ // Executa busca para cada Base DN
+ $all_results = [];
+ foreach ($base_dns as $base_dn) {
+ try {
+ $ldap = $this->ldapBridge->get();
+ $attr_options = [];
+ if (!empty($attributes)) {
+ $attr_options = ['filter' => $attributes];
+ }
+
+ $query = $ldap->query($base_dn, $filter, $attr_options);
+ $base_results = $query->execute();
+
+ if ($base_results instanceof \Traversable) {
+ $base_results = iterator_to_array($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(),
+ ]);
+ }
+ }
+
+ $entries = $all_results;
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao buscar entries LDAP: @message', ['@message' => $e->getMessage()]);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Builds a hierarchy map from LDAP department data.
+ *
+ * @param array $ldap_entries
+ * Array of LDAP Entry objects.
+ * @param string $parent_attribute
+ * LDAP attribute containing parent reference.
+ * @param string $child_attribute
+ * LDAP attribute containing child reference (not used currently).
+ *
+ * @return array
+ * Map of child codes to parent codes.
+ */
+ protected function buildHierarchyMap(array $ldap_entries, $parent_attribute, $child_attribute) {
+ $hierarchy_map = [];
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ $attribute_mappings = $config->get('attribute_mappings') ?: [];
+
+ // Find the LDAP attribute that maps to field_dept_code
+ $code_attribute = null;
+ foreach ($attribute_mappings as $mapping) {
+ if ($mapping['field'] === 'field_dept_code') {
+ $code_attribute = $mapping['attribute'];
+ break;
+ }
+ }
+
+ if (!$code_attribute) {
+ $this->logger->error('No attribute mapping found for field_dept_code. Cannot build hierarchy.');
+ return $hierarchy_map;
+ }
+
+ foreach ($ldap_entries as $entry) {
+ // Get the department code from LDAP entry
+ $dept_code = null;
+ if ($entry->hasAttribute($code_attribute)) {
+ $code_values = $entry->getAttribute($code_attribute);
+ if (is_array($code_values) && isset($code_values[0])) {
+ $dept_code = $code_values[0];
+ } elseif (!empty($code_values)) {
+ $dept_code = $code_values;
+ }
+ }
+
+ if (!$dept_code) {
+ continue;
+ }
+
+ // Get parent reference from LDAP
+ $parent_value = null;
+ if ($entry->hasAttribute($parent_attribute)) {
+ $parent_values = $entry->getAttribute($parent_attribute);
+ if (is_array($parent_values) && isset($parent_values[0])) {
+ $parent_value = $parent_values[0];
+ } elseif (!empty($parent_values)) {
+ $parent_value = $parent_values;
+ }
+ }
+
+ // Extract parent code from DN or direct value
+ if (!empty($parent_value)) {
+ // If parent is a DN, extract the code
+ if (preg_match('/imeccDepartmentCode=([^,]+)/i', $parent_value, $matches)) {
+ $parent_code = $matches[1];
+ } else {
+ $parent_code = $parent_value;
+ }
+ $hierarchy_map[$dept_code] = $parent_code;
+ }
+ }
+
+ $this->logger->info('Built hierarchy map with @count relationships', [
+ '@count' => count($hierarchy_map),
+ ]);
+
+ return $hierarchy_map;
+ }
+
+ /**
+ * Applies hierarchy to groups using custom field_parent_group field.
+ *
+ * @param array $hierarchy_map
+ * Map of child codes to parent codes.
+ */
+ protected function applyGroupHierarchy(array $hierarchy_map) {
+ $storage = $this->getEntityStorage();
+ $updated = 0;
+
+ $this->logger->info('Applying group hierarchy using custom field_parent_group field.');
+
+ foreach ($hierarchy_map as $child_code => $parent_code) {
+ $child_group = $this->getDepartmentByCode($child_code);
+ $parent_group = $this->getDepartmentByCode($parent_code);
+
+ if ($child_group && $parent_group) {
+ // Check if child group has field_parent_group
+ if (!$child_group->hasField('field_parent_group')) {
+ $this->logger->warning('Group @child does not have field_parent_group. Please run database updates.', [
+ '@child' => $child_group->label(),
+ ]);
+ continue;
+ }
+
+ // Check if parent is already set correctly
+ $current_parent_id = $child_group->get('field_parent_group')->target_id;
+ if ($current_parent_id != $parent_group->id()) {
+ try {
+ // Set the parent group
+ $child_group->set('field_parent_group', $parent_group->id());
+ $child_group->save();
+ $updated++;
+ $this->logger->info('Set parent group for @child to @parent', [
+ '@child' => $child_group->label(),
+ '@parent' => $parent_group->label(),
+ ]);
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to set parent for @child to @parent: @error', [
+ '@child' => $child_group->label(),
+ '@parent' => $parent_group->label(),
+ '@error' => $e->getMessage(),
+ ]);
+ }
+ } else {
+ $this->logger->debug('Parent group already set for @child', [
+ '@child' => $child_group->label(),
+ ]);
+ }
+ } else {
+ $this->logger->warning('Could not find groups for hierarchy relationship. Child code: @child_code, Parent code: @parent_code', [
+ '@child_code' => $child_code,
+ '@parent_code' => $parent_code,
+ ]);
+ }
+ }
+
+ $this->logger->info('Group hierarchy applied. Updated @count group relationships.', [
+ '@count' => $updated,
+ ]);
+ }
+
+ /**
+ * Returns the group entity storage.
+ *
+ * @return \Drupal\Core\Entity\EntityStorageInterface
+ * Entity storage for group.
+ */
+ protected function getEntityStorage() {
+ return $this->entityTypeManager->getStorage('group');
+ }
+
+ /**
+ * Returns the group type ID from configuration.
+ *
+ * @return string
+ * The group type ID.
+ */
+ protected function getBundleId() {
+ $config = $this->configFactory->get('ldap_departments_sync.settings');
+ return $config->get('group_type_id') ?: 'departments';
+ }
+
+ /**
+ * Returns the name field for groups.
+ *
+ * @return string
+ * Always returns 'label' for groups.
+ */
+ protected function getNameField() {
+ return 'label';
+ }
+
+ /**
+ * Returns the bundle field name for groups.
+ *
+ * @return string
+ * Always returns 'type' for groups.
+ */
+ protected function getBundleField() {
+ return 'type';
+ }
+
+}
diff --git a/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php b/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php
new file mode 100644
index 0000000..4f6a5af
--- /dev/null
+++ b/ldap_departments_sync/src/Plugin/EntityReferenceSelection/DepartmentSelection.php
@@ -0,0 +1,105 @@
+ FALSE,
+ 'allowed_types' => [],
+ ] + parent::defaultConfiguration();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildConfigurationForm($form, $form_state);
+
+ // Força departments como bundle padrão
+ $form['target_bundles']['#default_value'] = ['departments' => 'departments'];
+
+ $configuration = $this->getConfiguration();
+
+ $form['filter_by_type'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Filter by department type'),
+ '#description' => $this->t('Only show departments of specific types (field_dept_type).'),
+ '#default_value' => $configuration['filter_by_type'],
+ '#weight' => 10,
+ ];
+
+ $form['allowed_types'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Allowed department types'),
+ '#options' => [
+ 'academico' => $this->t('Acadêmico'),
+ 'administrativo' => $this->t('Administrativo'),
+ ],
+ '#default_value' => $configuration['allowed_types'],
+ '#weight' => 11,
+ '#states' => [
+ 'visible' => [
+ ':input[name="settings[handler_settings][filter_by_type]"]' => ['checked' => TRUE],
+ ],
+ ],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+ parent::validateConfigurationForm($form, $form_state);
+
+ // Remove valores vazios de allowed_types
+ $allowed_types = $form_state->getValue(['settings', 'handler_settings', 'allowed_types']);
+ if (is_array($allowed_types)) {
+ $allowed_types = array_filter($allowed_types);
+ $form_state->setValue(['settings', 'handler_settings', 'allowed_types'], $allowed_types);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
+ $query = parent::buildEntityQuery($match, $match_operator);
+
+ $configuration = $this->getConfiguration();
+
+ // Força apenas departments
+ $query->condition('type', 'departments');
+
+ // Filtra por tipo de departamento se configurado
+ if (!empty($configuration['filter_by_type']) && !empty($configuration['allowed_types'])) {
+ $allowed_types = array_filter($configuration['allowed_types']);
+ if (!empty($allowed_types)) {
+ $query->condition('field_dept_type', array_keys($allowed_types), 'IN');
+ }
+ }
+
+ return $query;
+ }
+
+}
diff --git a/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po b/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po
new file mode 100644
index 0000000..d4e4a04
--- /dev/null
+++ b/ldap_departments_sync/translations/ldap_departments_sync.pt-br.po
@@ -0,0 +1,286 @@
+# Portuguese (Brazil) translation for LDAP Departments Sync module
+# Copyright (c) 2025
+# This file is distributed under the same license as the LDAP Departments Sync module.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: LDAP Departments Sync 1.0.0\n"
+"POT-Creation-Date: 2025-11-07 12:00+0000\n"
+"PO-Revision-Date: 2025-11-07 12: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"
+
+#: src/Controller/LocalModulesController.php
+msgid "Local Modules"
+msgstr "Módulos Locais"
+
+#: ldap_departments_sync.routing.yml
+msgid "LDAP Departments Sync"
+msgstr "Sincronização de Departamentos LDAP"
+
+#: ldap_departments_sync.module
+msgid "This module synchronizes departments from an LDAP server to groups through cron."
+msgstr "Este módulo sincroniza departamentos de um servidor LDAP para grupos através do cron."
+
+#: src/Form/LdapDepartmentsSyncConfigForm.php
+
+msgid "Manage Fields"
+msgstr "Gerenciar Campos"
+
+msgid "LDAP Server"
+msgstr "Servidor LDAP"
+
+msgid "Select the LDAP server configured for departments synchronization."
+msgstr "Selecione o servidor LDAP configurado para sincronização de departamentos."
+
+msgid "- Select a server -"
+msgstr "- Selecione um servidor -"
+
+msgid "No LDAP server found. Configure an LDAP server first."
+msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro."
+
+msgid "LDAP Query"
+msgstr "Consulta LDAP"
+
+msgid "Select the LDAP query that will be used to search for departments."
+msgstr "Selecione a consulta LDAP que será usada para buscar departamentos."
+
+msgid "- Select a query -"
+msgstr "- Selecione uma query -"
+
+msgid "No LDAP query found for the selected server. Create a new LDAP query."
+msgstr "Nenhuma query LDAP encontrada para o servidor selecionado. Crie uma nova query LDAP."
+
+msgid "Create New LDAP Query"
+msgstr "Criar Nova Query LDAP"
+
+msgid "Edit Selected Query"
+msgstr "Editar Query Selecionada"
+
+msgid "Hierarchy"
+msgstr "Hierarquia"
+
+msgid "Configure whether departments should be organized hierarchically based on LDAP attributes."
+msgstr "Configure se os departamentos devem ser organizados hierarquicamente baseado em atributos LDAP."
+
+msgid "Enable hierarchy"
+msgstr "Habilitar hierarquização"
+
+msgid "When enabled, departments will be organized hierarchically based on the specified parent and child attributes."
+msgstr "Quando habilitado, os departamentos serão organizados hierarquicamente baseado nos atributos parent e child especificados."
+
+msgid "Parent Attribute"
+msgstr "Atributo Pai"
+
+msgid "LDAP attribute that contains the parent department code (e.g. departmentNumber)."
+msgstr "Atributo LDAP que contém o código do departamento pai (ex: departmentNumber)."
+
+msgid "- Select an attribute -"
+msgstr "- Selecione um atributo -"
+
+msgid "Child Attribute"
+msgstr "Atributo Filho"
+
+msgid "LDAP attribute that contains the department's own code (e.g. imeccDepartmentCode)."
+msgstr "Atributo LDAP que contém o código próprio do departamento (ex: imeccDepartmentCode)."
+
+msgid "All elements with the same child value will be nested under the corresponding parent recursively."
+msgstr "Todos os elementos com o mesmo valor de child serão aninhados sob o parent correspondente recursivamente."
+
+msgid "Attribute Mapping"
+msgstr "Mapeamento de Atributos"
+
+msgid "Configure how LDAP attributes are mapped to group fields.
Note: For \"User Reference\" mappings, the search attribute will be automatically obtained from the \"Account name attribute\" field (or \"Authentication name attribute\" as fallback) configured in the selected LDAP server."
+msgstr "Configure como os atributos LDAP são mapeados para os campos do grupo.
Nota: Para mapeamentos de \"Referência de Usuário\", o atributo de busca será automaticamente obtido do campo \"Account name attribute\" (ou \"Authentication name attribute\" como fallback) configurado no servidor LDAP selecionado."
+
+msgid "Entity Field"
+msgstr "Campo da Entidade"
+
+msgid "Group Field"
+msgstr "Campo do Grupo"
+
+msgid "LDAP Attribute"
+msgstr "Atributo LDAP"
+
+msgid "Mapping Type"
+msgstr "Tipo de Mapeamento"
+
+msgid "Remove"
+msgstr "Remover"
+
+msgid "No mappings configured."
+msgstr "Nenhum mapeamento configurado."
+
+msgid "- Select a field -"
+msgstr "- Selecione um campo -"
+
+msgid "Simple (text)"
+msgstr "Simples (texto)"
+
+msgid "User Reference (DN → User)"
+msgstr "Referência de Usuário (DN → User)"
+
+msgid "Add Mapping"
+msgstr "Adicionar Mapeamento"
+
+msgid "Remove Selected"
+msgstr "Remover Selecionados"
+
+msgid "Save Configuration"
+msgstr "Salvar Configurações"
+
+msgid "Test Connection"
+msgstr "Testar Conexão"
+
+msgid "Synchronize Departments"
+msgstr "Sincronizar Departamentos"
+
+msgid "Error loading LDAP servers: @message"
+msgstr "Erro ao carregar servidores LDAP: @message"
+
+msgid "Error loading group fields: @message"
+msgstr "Erro ao carregar campos do grupo: @message"
+
+msgid "Error loading query attributes: @message"
+msgstr "Erro ao carregar atributos da query: @message"
+
+msgid "LDAP attribute is required when field is selected."
+msgstr "Atributo LDAP é obrigatório quando campo está selecionado."
+
+msgid "Group field is required when attribute is selected."
+msgstr "Campo do grupo é obrigatório quando atributo está selecionado."
+
+msgid "Configuration saved successfully."
+msgstr "Configurações salvas com sucesso."
+
+msgid "Group Role Mapping"
+msgstr "Mapeamento de Papéis em Grupos"
+
+msgid "Configure how user attributes map to group roles. Each role can have its own mapping criteria."
+msgstr "Configure como atributos de usuário mapeiam para papéis em grupos. Cada papel pode ter seus próprios critérios de mapeamento."
+
+msgid "Configure how to assign group roles based on LDAP attributes or user fields."
+msgstr "Configure como atribuir papéis em grupos baseado em atributos LDAP ou campos de usuário."
+
+msgid "Enable role mapping"
+msgstr "Habilitar mapeamento de papéis"
+
+msgid "When enabled, users will be assigned group roles based on the mappings below."
+msgstr "Quando habilitado, os usuários receberão papéis em grupos baseado nos mapeamentos abaixo."
+
+msgid "Group Role"
+msgstr "Papel do Grupo"
+
+msgid "Source"
+msgstr "Origem"
+
+msgid "LDAP Attribute"
+msgstr "Atributo LDAP"
+
+msgid "User Field"
+msgstr "Campo do Usuário"
+
+msgid "Values (comma-separated)"
+msgstr "Valores (separados por vírgula)"
+
+msgid "No role mappings configured."
+msgstr "Nenhum mapeamento de papel configurado."
+
+msgid "- Select a role -"
+msgstr "- Selecione um papel -"
+
+msgid "Add Role Mapping"
+msgstr "Adicionar Mapeamento de Papel"
+
+msgid "Remove Selected"
+msgstr "Remover Selecionados"
+
+msgid "Default Group Role"
+msgstr "Papel Padrão do Grupo"
+
+msgid "The default role to assign if no role mapping matches."
+msgstr "O papel padrão a ser atribuído se nenhum mapeamento corresponder."
+
+msgid "Enter the expected values for this field/attribute, separated by commas (e.g., admin,administrator,manager)."
+msgstr "Digite os valores esperados para este campo/atributo, separados por vírgula (ex: admin,administrador,gerente)."
+
+msgid "Internal"
+msgstr "Interno"
+
+msgid "Outsider"
+msgstr "Externo"
+
+msgid "- Select attribute/field -"
+msgstr "- Selecione atributo/campo -"
+
+msgid "Group Field Match"
+msgstr "Correspondência de Campo do Grupo"
+
+msgid "User Field / LDAP Attribute"
+msgstr "Campo do Usuário / Atributo LDAP"
+
+msgid "Group Field / Values"
+msgstr "Campo do Grupo / Valores"
+
+msgid "- Select group field -"
+msgstr "- Selecione campo do grupo -"
+
+msgid "- Select user field -"
+msgstr "- Selecione campo do usuário -"
+
+msgid "The default \"departments\" group type is not installed."
+msgstr "O tipo de grupo padrão \"departments\" não está instalado."
+
+msgid "Install Default Configuration"
+msgstr "Instalar Configuração Padrão"
+
+msgid "The button above will create:- Group type \"departments\" with all required fields
- User fields for department synchronization
- Default group roles and displays
Or you can create a custom group type manually."
+msgstr "O botão acima irá criar:- Tipo de grupo \"departments\" com todos os campos necessários
- Campos de usuário para sincronização de departamento
- Papéis e visualizações de grupo padrão
Ou você pode criar um tipo de grupo personalizado manualmente."
+
+msgid "Successfully installed @count configuration items. Group type \"departments\" and all required fields have been created. Please reload the page to see the changes."
+msgstr "@count itens de configuração instalados com sucesso. O tipo de grupo \"departments\" e todos os campos necessários foram criados. Por favor, recarregue a página para ver as alterações."
+
+msgid "All configurations already exist. Nothing was imported."
+msgstr "Todas as configurações já existem. Nada foi importado."
+
+msgid "Error installing default configuration: @message"
+msgstr "Erro ao instalar configuração padrão: @message"
+
+msgid "Configuration directory not found: @path"
+msgstr "Diretório de configuração não encontrado: @path"
+
+msgid "Configuration @name already exists, skipping."
+msgstr "Configuração @name já existe, ignorando."
+
+#: src/Plugin/EntityReferenceSelection/DepartmentSelection.php
+msgid "Filter by department type"
+msgstr "Filtrar por tipo de departamento"
+
+msgid "Only show departments of specific types (field_dept_type)."
+msgstr "Mostrar apenas departamentos de tipos específicos (field_dept_type)."
+
+msgid "Allowed department types"
+msgstr "Tipos de departamento permitidos"
+
+msgid "Acadêmico"
+msgstr "Acadêmico"
+
+msgid "Administrativo"
+msgstr "Administrativo"
+
+msgid "Department selection"
+msgstr "Seleção de departamento"
+
+#: ldap_departments_sync.links.task.yml
+msgid "Configuration"
+msgstr "Configuração"
+
+msgid "Access Rules"
+msgstr "Regras de Acesso"
+
+msgid "Access Rule"
+msgstr "Regra de Acesso"
\ No newline at end of file
diff --git a/ldap_departments_sync/translations/pt-br.po b/ldap_departments_sync/translations/pt-br.po
new file mode 100644
index 0000000..4414fcf
--- /dev/null
+++ b/ldap_departments_sync/translations/pt-br.po
@@ -0,0 +1,152 @@
+# Portuguese translation of LDAP Departments Sync
+# Copyright (c) 2025
+msgid ""
+msgstr ""
+"Project-Id-Version: LDAP Departments Sync 1.0.0\n"
+"POT-Creation-Date: 2025-01-12 10:00-0300\n"
+"PO-Revision-Date: 2025-01-12 10:00-0300\n"
+"Language-Team: Portuguese (Brazil)\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"
+"Language: pt_BR\n"
+
+# Form titles and sections
+msgid "Synchronization Type"
+msgstr "Tipo de Sincronização"
+
+msgid "Data storage type"
+msgstr "Tipo de armazenamento de dados"
+
+msgid "Choose whether departments should be synchronized as taxonomy terms or as groups."
+msgstr "Escolha se os departamentos devem ser sincronizados como termos de taxonomia ou como grupos."
+
+msgid "Taxonomy Vocabulary - Store departments as taxonomy terms"
+msgstr "Vocabulário de Taxonomia - Armazenar departamentos como termos de taxonomia"
+
+msgid "Groups - Store departments as group entities"
+msgstr "Grupos - Armazenar departamentos como entidades de grupo"
+
+msgid "Updating form..."
+msgstr "Atualizando formulário..."
+
+msgid "Taxonomy Vocabulary"
+msgstr "Vocabulário de Taxonomia"
+
+msgid "Target Vocabulary"
+msgstr "Vocabulário de Destino"
+
+msgid "Select the taxonomy vocabulary where departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail"
+msgstr "Selecione o vocabulário de taxonomia onde os departamentos serão sincronizados.
Campos obrigatórios: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail"
+
+msgid "- Select a vocabulary -"
+msgstr "- Selecione um vocabulário -"
+
+msgid "No taxonomy vocabulary found. Create a vocabulary first."
+msgstr "Nenhum vocabulário de taxonomia encontrado. Crie um vocabulário primeiro."
+
+msgid "Create New Vocabulary"
+msgstr "Criar Novo Vocabulário"
+
+msgid "Manage Vocabulary"
+msgstr "Gerenciar Vocabulário"
+
+msgid "Manage Fields"
+msgstr "Gerenciar Campos"
+
+msgid "Group Type"
+msgstr "Tipo de Grupo"
+
+msgid "Target Group Type"
+msgstr "Tipo de Grupo de Destino"
+
+msgid "Select the group type where departments will be synchronized.
Required fields: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail"
+msgstr "Selecione o tipo de grupo onde os departamentos serão sincronizados.
Campos obrigatórios: field_dept_code, field_dept_acronym, field_dept_type, field_dept_phone, field_dept_room, field_dept_mail"
+
+msgid "- Select a group type -"
+msgstr "- Selecione um tipo de grupo -"
+
+msgid "No group type found. Create a group type first."
+msgstr "Nenhum tipo de grupo encontrado. Crie um tipo de grupo primeiro."
+
+msgid "Create New Group Type"
+msgstr "Criar Novo Tipo de Grupo"
+
+msgid "Manage Group Type"
+msgstr "Gerenciar Tipo de Grupo"
+
+msgid "LDAP Server"
+msgstr "Servidor LDAP"
+
+msgid "Select the LDAP server configured for departments synchronization."
+msgstr "Selecione o servidor LDAP configurado para sincronização de departamentos."
+
+msgid "- Select a server -"
+msgstr "- Selecione um servidor -"
+
+msgid "No LDAP server found. Configure an LDAP server first."
+msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro."
+
+msgid "LDAP Query"
+msgstr "Consulta LDAP"
+
+msgid "Select the LDAP query that will be used to search for departments."
+msgstr "Selecione a consulta LDAP que será usada para buscar departamentos."
+
+msgid "- Select a query -"
+msgstr "- Selecione uma consulta -"
+
+msgid "No LDAP query found for the selected server. Create a new LDAP query."
+msgstr "Nenhuma consulta LDAP encontrada para o servidor selecionado. Crie uma nova consulta LDAP."
+
+msgid "Create New LDAP Query"
+msgstr "Criar Nova Consulta LDAP"
+
+msgid "Edit Selected Query"
+msgstr "Editar Consulta Selecionada"
+
+msgid "Hierarchy"
+msgstr "Hierarquia"
+
+msgid "Configure whether departments should be organized hierarchically based on LDAP attributes."
+msgstr "Configure se os departamentos devem ser organizados hierarquicamente com base em atributos LDAP."
+
+msgid "Enable hierarchy"
+msgstr "Habilitar hierarquia"
+
+msgid "When enabled, terms will be organized hierarchically based on the specified parent and child attributes."
+msgstr "Quando habilitado, os departamentos serão organizados hierarquicamente com base nos atributos de pai e filho especificados."
+
+msgid "Parent Attribute"
+msgstr "Atributo Pai"
+
+msgid "LDAP attribute that contains the parent department code (e.g. departmentNumber)."
+msgstr "Atributo LDAP que contém o código do departamento pai (ex: departmentNumber)."
+
+msgid "Child Attribute"
+msgstr "Atributo Filho"
+
+msgid "LDAP attribute that contains the department's own code (e.g. imeccDepartmentCode)."
+msgstr "Atributo LDAP que contém o código próprio do departamento (ex: imeccDepartmentCode)."
+
+msgid "- Select an attribute -"
+msgstr "- Selecione um atributo -"
+
+msgid "All elements with the same child value will be nested under the corresponding parent recursively."
+msgstr "Todos os elementos com o mesmo valor filho serão aninhados sob o pai correspondente recursivamente."
+
+msgid "Attribute Mapping"
+msgstr "Mapeamento de Atributos"
+
+msgid "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 (or \"Authentication name attribute\" as fallback) configured in the selected LDAP server."
+msgstr "Configure como os atributos LDAP são mapeados para os campos da entidade de destino.
Nota: Para mapeamentos de \"Referência de Usuário\", o atributo de busca será obtido automaticamente do campo \"Account name attribute\" (ou \"Authentication name attribute\" como alternativa) configurado no servidor LDAP selecionado."
+
+msgid "- Select a field -"
+msgstr "- Selecione um campo -"
+
+msgid "Simple (text)"
+msgstr "Simples (texto)"
+
+msgid "User Reference (DN → User)"
+msgstr "Referência de Usuário (DN → Usuário)"
diff --git a/ldap_groups_sync.info.yml b/ldap_groups_sync.info.yml
new file mode 100644
index 0000000..7e8d8c8
--- /dev/null
+++ b/ldap_groups_sync.info.yml
@@ -0,0 +1,11 @@
+name: LDAP Groups Sync
+type: module
+description: 'Base module with shared access-rules infrastructure for LDAP group sync modules.'
+core_version_requirement: ^11
+package: Custom
+dependencies:
+ - drupal:options
+ - drupal:telephone
+ - group:group
+ - ldap:ldap_servers
+ - site_tools
diff --git a/ldap_research_groups_sync/README.md b/ldap_research_groups_sync/README.md
new file mode 100644
index 0000000..981caa8
--- /dev/null
+++ b/ldap_research_groups_sync/README.md
@@ -0,0 +1,69 @@
+# LDAP Research Groups Sync
+
+Módulo Drupal 11 que sincroniza grupos de pesquisa do servidor LDAP corporativo com entidades do tipo `research_group` (módulo Group).
+
+## Funcionalidades
+
+- Criação e atualização automática de grupos de pesquisa via cron a partir de uma LDAP query configurável
+- Sincronização de membros dos grupos a partir do atributo `member` (ou outro configurável) de cada entrada LDAP
+- Regras de acesso configuráveis por grupo
+- Proteção dos campos LDAP do usuário contra edição não autorizada
+
+## Dependências
+
+- `drupal:options`
+- `drupal:telephone`
+- `group:group`
+- `ldap:ldap_servers`
+- `site_tools`
+
+## Campos criados
+
+### No grupo (`research_group`)
+
+| Campo | Tipo | Descrição |
+|---|---|---|
+| `field_rg_code` | String | Código do grupo (chave de sincronização) |
+| `field_rg_phone` | Telephone | Telefone |
+| `field_rg_mail` | Email | E-mail |
+| `field_rg_coord` | Entity reference (user) | Coordenador do grupo |
+| `field_rg_coord_assoc` | Entity reference (user) | Coordenador associado do grupo |
+| `field_rg_department` | Entity reference (group) | Departamento ao qual o grupo está vinculado |
+
+### No usuário
+
+| Campo | Tipo | Descrição |
+|---|---|---|
+| `field_user_research_groups` | Entity reference (group, múltiplos) | Grupos de pesquisa do usuário (populado pelo sync) |
+
+## Instalação
+
+```bash
+drush en ldap_research_groups_sync
+drush cr
+```
+
+## Configuração
+
+Acesse `/admin/config/local-modules/ldap-research-groups-sync` e configure:
+
+1. **Group Type** — selecione `research_group`
+2. **LDAP Server** — servidor LDAP a ser usado
+3. **LDAP Query** — query entity que retorna as entradas dos grupos de pesquisa
+4. **Attribute Mappings** — mapeamento entre atributos LDAP e campos do grupo
+5. **Member Synchronization** — habilite e defina o atributo LDAP de membros (padrão: `member`)
+
+> **Atenção:** o atributo de membros (ex.: `member`) deve estar incluído nos atributos retornados pela LDAP query, ou a query não deve ter filtro de atributos.
+
+## Sincronização manual
+
+```bash
+drush php-eval "\Drupal::service('ldap_research_groups_sync.sync')->syncResearchGroups();"
+```
+
+A sincronização também é executada automaticamente pelo cron.
+
+## Permissões
+
+- `administer ldap research groups sync` — acesso à página de configuração
+- `edit ldap managed user rg fields` — permite editar o campo `field_user_research_groups` manualmente (normalmente reservado ao sync)
diff --git a/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml b/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml
new file mode 100644
index 0000000..707bbe0
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/core.entity_form_display.group.research_group.default.yml
@@ -0,0 +1,102 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.research_group.field_rg_code
+ - field.field.group.research_group.field_rg_coord
+ - field.field.group.research_group.field_rg_coord_assoc
+ - field.field.group.research_group.field_rg_mail
+ - field.field.group.research_group.field_rg_phone
+ - field.field.group.research_group.field_rg_department
+ - group.type.research_group
+ module:
+ - path
+id: group.research_group.default
+targetEntityType: group
+bundle: research_group
+mode: default
+content:
+ field_rg_code:
+ type: string_textfield
+ weight: 122
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_rg_mail:
+ type: email_default
+ weight: 125
+ region: content
+ settings:
+ placeholder: ''
+ size: 60
+ third_party_settings: { }
+ field_rg_phone:
+ type: string_textfield
+ weight: 124
+ region: content
+ settings:
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_rg_coord:
+ type: entity_reference_autocomplete
+ weight: 126
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_rg_coord_assoc:
+ type: entity_reference_autocomplete
+ weight: 127
+ region: content
+ settings:
+ match_operator: CONTAINS
+ match_limit: 10
+ size: 60
+ placeholder: ''
+ third_party_settings: { }
+ field_rg_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/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml b/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml
new file mode 100644
index 0000000..1420300
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/core.entity_view_display.group.research_group.default.yml
@@ -0,0 +1,81 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.field.group.research_group.field_rg_code
+ - field.field.group.research_group.field_rg_coord
+ - field.field.group.research_group.field_rg_coord_assoc
+ - field.field.group.research_group.field_rg_mail
+ - field.field.group.research_group.field_rg_phone
+ - field.field.group.research_group.field_rg_department
+ - group.type.research_group
+ module:
+ - options
+id: group.research_group.default
+targetEntityType: group
+bundle: research_group
+mode: default
+content:
+ field_rg_code:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -3
+ region: content
+ field_rg_mail:
+ type: basic_string
+ label: above
+ settings: { }
+ third_party_settings: { }
+ weight: 0
+ region: content
+ field_rg_phone:
+ type: string
+ label: above
+ settings:
+ link_to_entity: false
+ third_party_settings: { }
+ weight: -1
+ region: content
+ field_rg_coord:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 1
+ region: content
+ field_rg_coord_assoc:
+ type: entity_reference_label
+ label: above
+ settings:
+ link: true
+ third_party_settings: { }
+ weight: 2
+ region: content
+ field_rg_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/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml
new file mode 100644
index 0000000..c5a4390
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_code.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_code
+ - group.type.research_group
+id: group.research_group.field_rg_code
+field_name: field_rg_code
+entity_type: group
+bundle: research_group
+label: 'Código'
+description: 'Código do grupo de pesquisa (chave de sincronização LDAP)'
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: string
diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml
new file mode 100644
index 0000000..f3b8210
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord.yml
@@ -0,0 +1,30 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_coord
+ - group.type.research_group
+ module:
+ - user
+id: group.research_group.field_rg_coord
+field_name: field_rg_coord
+entity_type: group
+bundle: research_group
+label: Coordenador
+description: 'Usuário responsável pela coordenação do grupo de pesquisa'
+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/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml
new file mode 100644
index 0000000..adb5174
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_coord_assoc.yml
@@ -0,0 +1,30 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_coord_assoc
+ - group.type.research_group
+ module:
+ - user
+id: group.research_group.field_rg_coord_assoc
+field_name: field_rg_coord_assoc
+entity_type: group
+bundle: research_group
+label: 'Coordenador Associado'
+description: 'Usuário responsável pela coordenação associada do grupo de pesquisa'
+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/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml
new file mode 100644
index 0000000..fd71774
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_department.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_department
+ - group.type.department
+ - group.type.research_group
+id: group.research_group.field_rg_department
+field_name: field_rg_department
+entity_type: group
+bundle: research_group
+label: Departamento
+description: 'Departamento ao qual este grupo de pesquisa 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/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml
new file mode 100644
index 0000000..56a614a
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_mail.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_mail
+ - group.type.research_group
+id: group.research_group.field_rg_mail
+field_name: field_rg_mail
+entity_type: group
+bundle: research_group
+label: 'E-mail'
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: email
diff --git a/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml
new file mode 100644
index 0000000..c870b2d
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.group.research_group.field_rg_phone.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.group.field_rg_phone
+ - group.type.research_group
+ module:
+ - telephone
+id: group.research_group.field_rg_phone
+field_name: field_rg_phone
+entity_type: group
+bundle: research_group
+label: 'Telefone'
+description: ''
+required: false
+translatable: false
+default_value: { }
+default_value_callback: ''
+settings: { }
+field_type: telephone
diff --git a/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml b/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml
new file mode 100644
index 0000000..c4dfe40
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.field.user.user.field_user_research_groups.yml
@@ -0,0 +1,28 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - field.storage.user.field_user_research_groups
+ module:
+ - user
+id: user.user.field_user_research_groups
+field_name: field_user_research_groups
+entity_type: user
+bundle: user
+label: 'Grupos de Pesquisa'
+description: 'Grupos de pesquisa do usuário sincronizados do LDAP'
+required: false
+translatable: true
+default_value: { }
+default_value_callback: ''
+settings:
+ handler: 'default:group'
+ handler_settings:
+ target_bundles:
+ research_group: research_group
+ sort:
+ field: _none
+ direction: ASC
+ auto_create: false
+ auto_create_bundle: ''
+field_type: entity_reference
diff --git a/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml
new file mode 100644
index 0000000..cb38c77
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_code.yml
@@ -0,0 +1,20 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_rg_code
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml
new file mode 100644
index 0000000..e23e052
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_rg_coord
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml
new file mode 100644
index 0000000..d619ba1
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_coord_assoc.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: group.field_rg_coord_assoc
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml
new file mode 100644
index 0000000..b3cb2f1
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_department.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_rg_department
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml
new file mode 100644
index 0000000..caf7cbc
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_mail.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+id: group.field_rg_mail
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml
new file mode 100644
index 0000000..2fc1aeb
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.group.field_rg_phone.yml
@@ -0,0 +1,18 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - telephone
+id: group.field_rg_phone
+field_name: field_rg_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/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml b/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml
new file mode 100644
index 0000000..1f61695
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/field.storage.user.field_user_research_groups.yml
@@ -0,0 +1,19 @@
+langcode: pt-br
+status: true
+dependencies:
+ module:
+ - group
+ - user
+id: user.field_user_research_groups
+field_name: field_user_research_groups
+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/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml
new file mode 100644
index 0000000..aadd213
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin.yml
@@ -0,0 +1,37 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+id: research_group-admin
+label: Admin
+weight: 100
+admin: true
+scope: individual
+global_role: null
+group_type: research_group
+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/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml
new file mode 100644
index 0000000..cd121b8
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_in.yml
@@ -0,0 +1,38 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+ - user.role.administrator
+id: research_group-admin_in
+label: Administrador
+weight: 102
+admin: true
+scope: insider
+global_role: administrator
+group_type: research_group
+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/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml
new file mode 100644
index 0000000..9042a86
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-admin_out.yml
@@ -0,0 +1,38 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+ - user.role.administrator
+id: research_group-admin_out
+label: Administrador
+weight: 101
+admin: true
+scope: outsider
+global_role: administrator
+group_type: research_group
+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/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml
new file mode 100644
index 0000000..7da6323
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-anonymous.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+ - user.role.anonymous
+id: research_group-anonymous
+label: 'Anônimo'
+weight: -102
+admin: false
+scope: outsider
+global_role: anonymous
+group_type: research_group
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml
new file mode 100644
index 0000000..4eefd73
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-member.yml
@@ -0,0 +1,27 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+ - user.role.authenticated
+id: research_group-member
+label: Member
+weight: -100
+admin: false
+scope: insider
+global_role: authenticated
+group_type: research_group
+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/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml b/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml
new file mode 100644
index 0000000..a7b2f0b
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.role.research_group-outsider.yml
@@ -0,0 +1,17 @@
+langcode: pt-br
+status: true
+dependencies:
+ config:
+ - group.type.research_group
+ - user.role.authenticated
+id: research_group-outsider
+label: Outsider
+weight: -101
+admin: false
+scope: outsider
+global_role: authenticated
+group_type: research_group
+permissions:
+ - view group
+ - view group_node:article entity
+ - view group_node:page entity
diff --git a/ldap_research_groups_sync/config/optional/group.type.research_group.yml b/ldap_research_groups_sync/config/optional/group.type.research_group.yml
new file mode 100644
index 0000000..1531186
--- /dev/null
+++ b/ldap_research_groups_sync/config/optional/group.type.research_group.yml
@@ -0,0 +1,10 @@
+langcode: pt-br
+status: true
+dependencies: { }
+id: research_group
+label: 'Grupo de Pesquisa'
+description: ''
+new_revision: true
+creator_membership: true
+creator_wizard: true
+creator_roles: { }
diff --git a/ldap_research_groups_sync/css/role-mapping.css b/ldap_research_groups_sync/css/role-mapping.css
new file mode 100644
index 0000000..f5f17b1
--- /dev/null
+++ b/ldap_research_groups_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/ldap_research_groups_sync/ldap_research_groups_sync.info.yml b/ldap_research_groups_sync/ldap_research_groups_sync.info.yml
new file mode 100644
index 0000000..bb08431
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.info.yml
@@ -0,0 +1,13 @@
+name: LDAP Research Groups Sync
+type: module
+description: 'Sincroniza grupos de pesquisa 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/ldap_research_groups_sync/ldap_research_groups_sync.install b/ldap_research_groups_sync/ldap_research_groups_sync.install
new file mode 100644
index 0000000..00ef3c9
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.install
@@ -0,0 +1,94 @@
+addWarning(t('Please ensure your group type has the required fields: field_rg_code, field_rg_phone, field_rg_mail, and field_rg_department (for department reference).'));
+}
+
+
+/**
+ * Adiciona os campos field_rg_coord e field_rg_coord_assoc ao bundle research_group.
+ */
+function ldap_research_groups_sync_update_10001() {
+ $configs = [
+ 'field.storage.group.field_rg_coord',
+ 'field.storage.group.field_rg_coord_assoc',
+ 'field.field.group.research_group.field_rg_coord',
+ 'field.field.group.research_group.field_rg_coord_assoc',
+ ];
+
+ $module_path = \Drupal::service('extension.list.module')->getPath('ldap_research_groups_sync');
+ $config_path = $module_path . '/config/optional';
+ $config_factory = \Drupal::configFactory();
+ $yaml_parser = \Drupal::service('serialization.yaml');
+ $imported = 0;
+
+ foreach ($configs as $config_name) {
+ if (!$config_factory->get($config_name)->isNew()) {
+ continue;
+ }
+ $file = $config_path . '/' . $config_name . '.yml';
+ $data = $yaml_parser->decode(file_get_contents($file));
+ $config_factory->getEditable($config_name)->setData($data)->save();
+ $imported++;
+ }
+
+ if ($imported > 0) {
+ \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
+ \Drupal::service('entity_type.bundle.info')->clearCachedBundles();
+ }
+
+ return t('Campos field_rg_coord e field_rg_coord_assoc importados (@count configs).', ['@count' => $imported]);
+}
+
+
+/**
+ * Adiciona o campo field_rg_code ao bundle research_group.
+ */
+function ldap_research_groups_sync_update_10002() {
+ $configs = [
+ 'field.storage.group.field_rg_code',
+ 'field.field.group.research_group.field_rg_code',
+ ];
+
+ $module_path = \Drupal::service('extension.list.module')->getPath('ldap_research_groups_sync');
+ $config_path = $module_path . '/config/optional';
+ $config_factory = \Drupal::configFactory();
+ $yaml_parser = \Drupal::service('serialization.yaml');
+ $imported = 0;
+
+ foreach ($configs as $config_name) {
+ if (!$config_factory->get($config_name)->isNew()) {
+ continue;
+ }
+ $file = $config_path . '/' . $config_name . '.yml';
+ $data = $yaml_parser->decode(file_get_contents($file));
+ $config_factory->getEditable($config_name)->setData($data)->save();
+ $imported++;
+ }
+
+ if ($imported > 0) {
+ \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
+ \Drupal::service('entity_type.bundle.info')->clearCachedBundles();
+ }
+
+ return t('Campo field_rg_code importado (@count configs).', ['@count' => $imported]);
+}
+
+
+/**
+ * Implements hook_uninstall().
+ */
+function ldap_research_groups_sync_uninstall() {
+ // Remove configurações
+ \Drupal::configFactory()->getEditable('ldap_research_groups_sync.settings')->delete();
+
+ \Drupal::messenger()->addStatus(t('LDAP Research Groups Sync module uninstalled.'));
+}
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml b/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml
new file mode 100644
index 0000000..da24478
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.libraries.yml
@@ -0,0 +1,5 @@
+role_mapping_styles:
+ version: 1.x
+ css:
+ theme:
+ css/role-mapping.css: {}
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml b/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml
new file mode 100644
index 0000000..9687b76
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.links.menu.yml
@@ -0,0 +1,6 @@
+ldap_research_groups_sync.config:
+ title: 'LDAP Research Groups Sync'
+ description: 'Configurar sincronização de grupos de pesquisa do LDAP'
+ route_name: ldap_research_groups_sync.config
+ parent: site_tools.admin_config
+ weight: 6
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml b/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml
new file mode 100644
index 0000000..9e9dbff
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.links.task.yml
@@ -0,0 +1,11 @@
+ldap_research_groups_sync.tab.config:
+ title: 'Configuration'
+ route_name: ldap_research_groups_sync.config
+ base_route: ldap_research_groups_sync.config
+ weight: 0
+
+ldap_research_groups_sync.tab.access_rules:
+ title: 'Access Rules'
+ route_name: ldap_research_groups_sync.access_rules
+ base_route: ldap_research_groups_sync.config
+ weight: 10
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.module b/ldap_research_groups_sync/ldap_research_groups_sync.module
new file mode 100644
index 0000000..4bbf8a2
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.module
@@ -0,0 +1,238 @@
+' . t('This module synchronizes research groups from an LDAP server to groups through cron.') . '';
+ }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function ldap_research_groups_sync_cron() {
+ // Check if module is properly configured
+ $config = \Drupal::config('ldap_research_groups_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_research_groups_sync')->warning('Synchronization cancelled: LDAP server "@server_id" not found or inactive. Configure at /admin/config/local-modules/ldap-research-groups-sync', [
+ '@server_id' => $ldap_server_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_research_groups_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_research_groups_sync')->warning('Synchronization cancelled: LDAP query "@query_id" not found or inactive. Configure at /admin/config/local-modules/ldap-research-groups-sync', [
+ '@query_id' => $ldap_query_id,
+ ]);
+ return;
+ }
+ }
+ else {
+ \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: ldap_query module not available.');
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_research_groups_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_research_groups_sync')->warning('Synchronization cancelled: group type "@type_id" not found. Configure at /admin/config/local-modules/ldap-research-groups-sync', [
+ '@type_id' => $group_type_id,
+ ]);
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_research_groups_sync')->warning('Synchronization cancelled: error checking group type: @message', [
+ '@message' => $e->getMessage(),
+ ]);
+ return;
+ }
+
+ // Get LDAP synchronization service
+ $ldap_sync = \Drupal::service('ldap_research_groups_sync.sync');
+
+ // Execute research groups synchronization
+ try {
+ $ldap_sync->syncResearchGroups();
+ \Drupal::logger('ldap_research_groups_sync')->info('Research groups synchronization executed successfully.');
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('ldap_research_groups_sync')->error('Error in research groups 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_research_groups_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_research_groups_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_research_groups_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_research_groups_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_research_groups: multi-value reference to the research groups
+ * 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_research_groups_sync_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
+ $protected_user_fields = [
+ 'field_user_research_groups',
+ ];
+
+ 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 (LdapResearchGroupsSync::$syncing TRUE)
+ * - New user creation (initial provisioning via ldap_user)
+ * - Saves by users with the 'edit ldap managed user rg fields' permission
+ */
+function ldap_research_groups_sync_user_presave(\Drupal\user\UserInterface $user) {
+ // Permite durante qualquer sync LDAP autorizado.
+ if (LdapResearchGroupsSync::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 rg fields')) {
+ return;
+ }
+
+ $original = $user->original;
+ if ($original === NULL) {
+ return;
+ }
+
+ // Fields managed by LDAP that cannot be changed externally.
+ $protected_fields = [
+ 'field_user_research_groups',
+ ];
+
+ 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_research_groups_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/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml b/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml
new file mode 100644
index 0000000..deb35fb
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.permissions.yml
@@ -0,0 +1,9 @@
+administer ldap research groups sync:
+ title: 'Administer LDAP Research Groups Sync'
+ description: 'Configure the LDAP research groups synchronization.'
+ restrict access: true
+
+edit ldap managed user rg fields:
+ title: 'Edit LDAP-managed research group fields on users'
+ description: 'Allows manually editing fields such as field_user_research_groups that are normally managed by LDAP synchronization. Use only in emergencies.'
+ restrict access: true
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml b/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml
new file mode 100644
index 0000000..a4be2e7
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.routing.yml
@@ -0,0 +1,25 @@
+ldap_research_groups_sync.config:
+ path: '/admin/config/local-modules/ldap-research-groups-sync'
+ defaults:
+ _form: '\Drupal\ldap_research_groups_sync\Form\LdapResearchGroupsSyncConfigForm'
+ _title: 'LDAP Research Groups Sync'
+ requirements:
+ _permission: 'administer ldap research groups sync'
+
+ldap_research_groups_sync.access_rules:
+ path: '/admin/config/local-modules/ldap-research-groups-sync/access-rules'
+ defaults:
+ _form: '\Drupal\ldap_research_groups_sync\Form\AccessRulesForm'
+ _title: 'Access Rules'
+ requirements:
+ _permission: 'administer ldap research groups sync'
+
+ldap_research_groups_sync.access_rule_form:
+ path: '/admin/config/local-modules/ldap-research-groups-sync/access-rule/{rule_index}'
+ defaults:
+ _form: '\Drupal\ldap_research_groups_sync\Form\AccessRuleForm'
+ _title: 'Access Rule'
+ rule_index: 'new'
+ requirements:
+ _permission: 'administer ldap research groups sync'
+ rule_index: 'new|\d+'
diff --git a/ldap_research_groups_sync/ldap_research_groups_sync.services.yml b/ldap_research_groups_sync/ldap_research_groups_sync.services.yml
new file mode 100644
index 0000000..2ae2c70
--- /dev/null
+++ b/ldap_research_groups_sync/ldap_research_groups_sync.services.yml
@@ -0,0 +1,8 @@
+services:
+ ldap_research_groups_sync.sync:
+ class: Drupal\ldap_research_groups_sync\LdapResearchGroupsSync
+ arguments: ['@entity_type.manager', '@config.factory', '@logger.factory', '@ldap.bridge']
+
+ ldap_research_groups_sync.access_rules:
+ class: Drupal\ldap_groups_sync\GroupAccessRulesService
+ arguments: ['@config.factory', '@entity_type.manager', 'ldap_research_groups_sync.settings']
diff --git a/ldap_research_groups_sync/scripts/install_field_rg_department.php b/ldap_research_groups_sync/scripts/install_field_rg_department.php
new file mode 100644
index 0000000..edca816
--- /dev/null
+++ b/ldap_research_groups_sync/scripts/install_field_rg_department.php
@@ -0,0 +1,85 @@
+getPath('ldap_research_groups_sync');
+$config_path = $module_path . '/config/optional';
+
+$config_factory = \Drupal::configFactory();
+$yaml_parser = \Drupal::service('serialization.yaml');
+
+$configs_to_install = [
+ 'field.storage.group.field_rg_department',
+ 'field.field.group.research_group.field_rg_department',
+ 'core.entity_form_display.group.research_group.default',
+ 'core.entity_view_display.group.research_group.default',
+];
+
+$imported = 0;
+$skipped = 0;
+
+foreach ($configs_to_install as $config_name) {
+ $file = $config_path . '/' . $config_name . '.yml';
+
+ if (!file_exists($file)) {
+ echo "ERROR: File not found: $file\n";
+ continue;
+ }
+
+ $existing = $config_factory->get($config_name);
+
+ // For display configs, always update (they already exist and need the new field).
+ $is_display = str_starts_with($config_name, 'core.entity_');
+
+ if (!$existing->isNew() && !$is_display) {
+ echo "SKIP: $config_name (already exists)\n";
+ $skipped++;
+ continue;
+ }
+
+ $data = $yaml_parser->decode(file_get_contents($file));
+ $config_factory->getEditable($config_name)->setData($data)->save();
+
+ echo "OK: $config_name\n";
+ $imported++;
+}
+
+if ($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();
+
+ // Create the database table for the new field storage.
+ $field_manager = \Drupal::service('entity_field.manager');
+ $field_manager->clearCachedFieldDefinitions();
+ $update_manager = \Drupal::entityDefinitionUpdateManager();
+ $storage_defs = $field_manager->getFieldStorageDefinitions('group');
+ if (isset($storage_defs['field_rg_department'])) {
+ $update_manager->installFieldStorageDefinition(
+ 'field_rg_department',
+ 'group',
+ 'ldap_research_groups_sync',
+ $storage_defs['field_rg_department']
+ );
+ echo "Schema update applied (group__field_rg_department table created).\n";
+ }
+ else {
+ echo "WARNING: field_rg_department storage definition not found — table not created.\n";
+ }
+
+ \Drupal::service('router.builder')->rebuild();
+
+ echo "\nDone. Imported: $imported, Skipped: $skipped\n";
+ echo "Run 'drush cr' to clear all caches.\n";
+}
+else {
+ echo "\nNothing imported. Skipped: $skipped\n";
+}
diff --git a/ldap_research_groups_sync/src/Form/AccessRuleForm.php b/ldap_research_groups_sync/src/Form/AccessRuleForm.php
new file mode 100644
index 0000000..abaa3dc
--- /dev/null
+++ b/ldap_research_groups_sync/src/Form/AccessRuleForm.php
@@ -0,0 +1,40 @@
+entityTypeManager = $entity_type_manager;
+ $this->ldapResearchGroupsSync = $ldap_research_groups_sync;
+ $this->messenger = $messenger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('entity_type.manager'),
+ $container->get('ldap_research_groups_sync.sync'),
+ $container->get('messenger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['ldap_research_groups_sync.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'ldap_research_groups_sync_config_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $config = $this->config('ldap_research_groups_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 research groups will be synchronized.
Required fields: field_rg_code, field_rg_phone, field_rg_mail'),
+ '#options' => $group_types,
+ '#empty_option' => $this->t('- Select a group type -'),
+ '#default_value' => $config->get('group_type_id') ?: 'research_group',
+ '#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' => '
',
+ ];
+
+ $research_group_exists = isset($group_types['research_group']);
+
+ if (empty($group_types) || !$research_group_exists) {
+ $form['group_type']['setup_notice'] = [
+ '#type' => 'markup',
+ '#markup' => '' .
+ $this->t('The default "research_group" 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 "research_group" with all required fields
+ - User fields for research group 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') ?: 'research_group';
+ 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 research groups 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 research groups.'),
+ '#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') ?: 'research_group';
+ $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_research_groups_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_research_groups'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Synchronize Research Groups'),
+ '#submit' => ['::syncResearchGroups'],
+ '#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 research groups.
+ */
+ protected function getDefaultMappings() {
+ return [
+ ['field' => 'label', 'attribute' => 'description', 'mapping_type' => 'simple'],
+ ['field' => 'field_rg_code', 'attribute' => 'cn', 'mapping_type' => 'simple'],
+ ['field' => 'field_rg_mail', 'attribute' => 'mail', 'mapping_type' => 'simple'],
+ ['field' => 'field_rg_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_research_groups_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 grupos de pesquisa.
+ */
+ public function syncResearchGroups(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->ldapResearchGroupsSync->syncResearchGroups();
+ $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_research_groups_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_research_groups_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_research_groups_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/ldap_research_groups_sync/src/LdapResearchGroupsSync.php b/ldap_research_groups_sync/src/LdapResearchGroupsSync.php
new file mode 100644
index 0000000..19f79a5
--- /dev/null
+++ b/ldap_research_groups_sync/src/LdapResearchGroupsSync.php
@@ -0,0 +1,1212 @@
+entityTypeManager = $entity_type_manager;
+ $this->configFactory = $config_factory;
+ $this->logger = $logger_factory->get('ldap_research_groups_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 research groups from LDAP to groups.
+ */
+ public function syncResearchGroups() {
+ $this->logger->info('Starting research groups synchronization using configured LDAP query.');
+
+ $config = $this->configFactory->get('ldap_research_groups_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 {
+ $research_groups = $this->fetchResearchGroupsUsingQuery();
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro durante fetchResearchGroupsUsingQuery: @message', ['@message' => $e->getMessage()]);
+ throw $e;
+ }
+
+ if (empty($research_groups)) {
+ $this->logger->warning('Nenhum grupo de pesquisa encontrado no LDAP.');
+ return;
+ }
+
+ $this->logger->info('Iniciando sincronização de @count grupos de pesquisa', ['@count' => count($research_groups)]);
+
+ $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_rg_code') && !$entity->get('field_rg_code')->isEmpty()) {
+ $code = $entity->get('field_rg_code')->value;
+ $existing_codes[$code] = $entity;
+ }
+ }
+
+ $created = 0;
+ $updated = 0;
+
+ foreach ($research_groups as $rg_data) {
+ $code = $rg_data['code'];
+ $name = $rg_data['name'];
+ $phone = $rg_data['phone'];
+ $mail = $rg_data['mail'];
+
+ // Campos extras (incluindo referências de usuário)
+ $extra_fields = [];
+ foreach ($rg_data as $field => $value) {
+ if (!in_array($field, ['code', 'name', 'phone', 'mail', '_ldap_members'])) {
+ $extra_fields[$field] = $value;
+ }
+ }
+
+ $this->logger->info('Processando grupo de pesquisa - 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_rg_phone')) {
+ $entity->set('field_rg_phone', $phone);
+ }
+ if ($entity->hasField('field_rg_mail')) {
+ $entity->set('field_rg_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_rg_code' => $code,
+ 'field_rg_phone' => $phone,
+ 'field_rg_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 grupo de pesquisa criado ou atualizado.');
+ }
+
+ // Sincroniza membros dos grupos via atributo LDAP
+ $this->syncMembersFromLdapAttribute($research_groups);
+ }
+
+ /**
+ * Busca entidade de grupo de pesquisa pelo código.
+ *
+ * @param string $rg_code
+ * Código do grupo de pesquisa.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface|null
+ * Entidade grupo ou NULL se não encontrado.
+ */
+ public function getResearchGroupByCode($rg_code) {
+ if (empty($rg_code)) {
+ return NULL;
+ }
+
+ $bundle_id = $this->getBundleId();
+ $bundle_field = $this->getBundleField();
+ $entity_storage = $this->getEntityStorage();
+
+ $entities = $entity_storage->loadByProperties([
+ $bundle_field => $bundle_id,
+ 'field_rg_code' => $rg_code,
+ ]);
+
+ return !empty($entities) ? reset($entities) : NULL;
+ }
+
+ /**
+ * Busca grupos de pesquisa usando a query LDAP configurada.
+ *
+ * @return array
+ * Array de grupos de pesquisa com código e descrição.
+ */
+ protected function fetchResearchGroupsUsingQuery() {
+ $research_groups = [];
+
+ $this->logger->info('Iniciando fetchResearchGroupsUsingQuery...');
+
+ 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 $research_groups;
+ }
+
+ $this->logger->info('Storage de queries LDAP está disponível.');
+
+ try {
+ $config = $this->configFactory->get('ldap_research_groups_sync.settings');
+ $query_id = $config->get('ldap_query_id') ?: 'research_group_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-research-groups-sync', [
+ '@query_id' => $query_id,
+ ]);
+ return $research_groups;
+ }
+
+ $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 $research_groups;
+ }
+
+ $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 $research_groups;
+ }
+
+ $this->logger->info('Query LDAP retornou @count resultados', ['@count' => count($results)]);
+
+ $research_groups = $this->processLdapResults($results);
+
+ $this->logger->info('Processados @count grupos de pesquisa via query LDAP', ['@count' => count($research_groups)]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao executar query LDAP: @message', ['@message' => $e->getMessage()]);
+ }
+
+ return $research_groups;
+ }
+
+ /**
+ * 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_research_groups_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 grupos de pesquisa usando o atributo member do LDAP.
+ *
+ * Para cada grupo de pesquisa, 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 $research_groups
+ * Array de dados dos grupos processados, com chave '_ldap_members'.
+ */
+ public function syncMembersFromLdapAttribute(array $research_groups) {
+ $config = $this->configFactory->get('ldap_research_groups_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 ($research_groups as $rg_data) {
+ $code = $rg_data['code'];
+ $ldap_members = $rg_data['_ldap_members'] ?? [];
+
+ if (empty($ldap_members)) {
+ $this->logger->debug('No LDAP members for group @code.', ['@code' => $code]);
+ }
+
+ // Localiza a entidade grupo correspondente
+ $groups = $group_storage->loadByProperties([
+ $bundle_field => $bundle_id,
+ 'field_rg_code' => $code,
+ ]);
+
+ if (empty($groups)) {
+ $this->logger->warning('Group 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 group @group.', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ ]);
+ // Append group to field_user_research_groups if not already there.
+ if ($user->hasField('field_user_research_groups')) {
+ $existing = $user->get('field_user_research_groups')->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_research_groups')->appendItem(['target_id' => $group->id()]);
+ $user->save();
+ }
+ finally {
+ self::$syncing = FALSE;
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to add user @uid to group @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 group @group.', [
+ '@uid' => $uid,
+ '@group' => $group->label(),
+ ]);
+ // Remove group from field_user_research_groups if present.
+ if ($user->hasField('field_user_research_groups')) {
+ $existing = $user->get('field_user_research_groups')->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_research_groups', $updated);
+ $user->save();
+ }
+ finally {
+ self::$syncing = FALSE;
+ }
+ }
+ }
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Failed to remove user @uid from group @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_research_groups_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 research groups.
+ */
+ protected function processLdapResults(array $results) {
+ $research_groups = [];
+ $config = $this->configFactory->get('ldap_research_groups_sync.settings');
+ $attribute_mappings = $config->get('attribute_mappings') ?: [];
+
+ $code_mapping = NULL;
+ foreach ($attribute_mappings as $mapping) {
+ if ($mapping['field'] === 'field_rg_code') {
+ $code_mapping = $mapping;
+ break;
+ }
+ }
+
+ if (!$code_mapping) {
+ $this->logger->error('No field_rg_code mapping found. Cannot process results.');
+ return $research_groups;
+ }
+
+ $this->logger->debug('Processing @count LDAP results with @mappings mappings', [
+ '@count' => count($results),
+ '@mappings' => count($attribute_mappings),
+ ]);
+
+ foreach ($results as $entry) {
+ $rg_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);
+ $rg_data[$field] = $dept_id;
+ }
+ else {
+ $rg_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) {
+ $rg_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,
+ ]);
+ $rg_data[$field] = NULL;
+ }
+ }
+ else {
+ $rg_data[$field] = NULL;
+ }
+ }
+ else {
+ $rg_data[$field] = $value;
+ }
+ }
+
+ $code = $rg_data['field_rg_code'] ?? NULL;
+ if ($code) {
+ $result = [
+ 'code' => $code,
+ 'name' => $rg_data['label'] ?? $rg_data['name'] ?? '',
+ 'phone' => $rg_data['field_rg_phone'] ?? '',
+ 'mail' => $rg_data['field_rg_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_rg_fields = ['field_rg_code', 'label', 'name', 'field_rg_phone', 'field_rg_mail'];
+ foreach ($rg_data as $key => $value) {
+ if (!in_array($key, $basic_rg_fields)) {
+ $result[$key] = $value;
+ }
+ }
+
+ $research_groups[] = $result;
+ }
+ }
+
+ return $research_groups;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Fetches raw LDAP Entry objects from the query.
+ */
+ protected function fetchRawLdapEntries() {
+ $entries = [];
+
+ if (!$this->ldapQueryStorage) {
+ $this->logger->error('Storage de queries LDAP não está disponível.');
+ return $entries;
+ }
+
+ try {
+ $config = $this->configFactory->get('ldap_research_groups_sync.settings');
+ $query_id = $config->get('ldap_query_id') ?: 'research_group_sync';
+
+ $query_entity = $this->ldapQueryStorage->load($query_id);
+
+ if (!$query_entity || !$query_entity->get('status')) {
+ $this->logger->error('Query LDAP não está disponível ou desabilitada.');
+ return $entries;
+ }
+
+ $server_id = $query_entity->getServerId();
+ $base_dns = $query_entity->getProcessedBaseDns();
+ $filter = $query_entity->getFilter();
+ $attributes = $query_entity->getProcessedAttributes();
+
+ $this->ldapBridge->setServerById($server_id);
+
+ if (!$this->ldapBridge->bind()) {
+ throw new \Exception('Falha ao conectar ao servidor LDAP');
+ }
+
+ $all_results = [];
+ foreach ($base_dns as $base_dn) {
+ try {
+ $ldap = $this->ldapBridge->get();
+ $attr_options = [];
+ if (!empty($attributes)) {
+ $attr_options = ['filter' => $attributes];
+ }
+
+ $query = $ldap->query($base_dn, $filter, $attr_options);
+ $base_results = $query->execute();
+
+ if ($base_results instanceof \Traversable) {
+ $base_results = iterator_to_array($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(),
+ ]);
+ }
+ }
+
+ $entries = $all_results;
+ }
+ catch (\Exception $e) {
+ $this->logger->error('Erro ao buscar entries LDAP: @message', ['@message' => $e->getMessage()]);
+ }
+
+ return $entries;
+ }
+
+ /**
+ * 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_research_groups_sync.settings');
+ return $config->get('group_type_id') ?: 'research_group';
+ }
+
+ /**
+ * 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/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php b/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php
new file mode 100644
index 0000000..3d9159e
--- /dev/null
+++ b/ldap_research_groups_sync/src/Plugin/EntityReferenceSelection/ResearchGroupSelection.php
@@ -0,0 +1,31 @@
+condition('type', 'research_group');
+ return $query;
+ }
+
+}
diff --git a/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po b/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po
new file mode 100644
index 0000000..cfe9436
--- /dev/null
+++ b/ldap_research_groups_sync/translations/ldap_research_groups_sync.pt-br.po
@@ -0,0 +1,298 @@
+# Portuguese (Brazil) translation for LDAP Research Groups Sync module
+# Copyright (c) 2026
+# This file is distributed under the same license as the LDAP Research Groups Sync module.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: LDAP Research Groups Sync 1.0.0\n"
+"POT-Creation-Date: 2026-02-27 00:00+0000\n"
+"PO-Revision-Date: 2026-02-27 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_research_groups_sync.routing.yml
+msgid "LDAP Research Groups Sync"
+msgstr "Sincronização de Grupos de Pesquisa LDAP"
+
+#: ldap_research_groups_sync.routing.yml
+msgid "Access Rules"
+msgstr "Regras de Acesso"
+
+#: ldap_research_groups_sync.routing.yml
+msgid "Access Rule"
+msgstr "Regra de Acesso"
+
+#: ldap_research_groups_sync.module
+msgid "This module synchronizes research groups from an LDAP server to groups through cron."
+msgstr "Este módulo sincroniza grupos de pesquisa de um servidor LDAP para grupos através do cron."
+
+#: ldap_research_groups_sync.install
+msgid "Please ensure your group type has the required fields: field_rg_code, field_rg_phone, field_rg_mail, and field_rg_department (for department reference)."
+msgstr "Certifique-se de que o tipo de grupo possui os campos obrigatórios: field_rg_code, field_rg_phone, field_rg_mail e field_rg_department (para referência de departamento)."
+
+msgid "LDAP Research Groups Sync module uninstalled."
+msgstr "Módulo LDAP Research Groups Sync desinstalado."
+
+#: src/Form/LdapResearchGroupsSyncConfigForm.php
+
+msgid "Group Type"
+msgstr "Tipo de Grupo"
+
+msgid "Target Group Type"
+msgstr "Tipo de Grupo Alvo"
+
+msgid "Select the group type where research groups will be synchronized.
Required fields: field_rg_code, field_rg_phone, field_rg_mail"
+msgstr "Selecione o tipo de grupo onde os grupos de pesquisa serão sincronizados.
Campos obrigatórios: field_rg_code, field_rg_phone, field_rg_mail"
+
+msgid "- Select a group type -"
+msgstr "- Selecione um tipo de grupo -"
+
+msgid "The default \"research_group\" group type is not installed."
+msgstr "O tipo de grupo padrão \"research_group\" não está instalado."
+
+msgid "Install Default Configuration"
+msgstr "Instalar Configuração Padrão"
+
+msgid "The button above will create:\n - Group type \"research_group\" with all required fields
\n - User fields for research group 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 \"research_group\" com todos os campos obrigatórios
\n - Campos de usuário para sincronização de grupo de pesquisa
\n - Papéis e exibições padrão do grupo
\n
Ou você pode criar um tipo de grupo personalizado manualmente."
+
+msgid "Create New Group Type"
+msgstr "Criar Novo Tipo de Grupo"
+
+msgid "Manage Group Type"
+msgstr "Gerenciar Tipo de Grupo"
+
+msgid "Manage Fields"
+msgstr "Gerenciar Campos"
+
+msgid "LDAP Server"
+msgstr "Servidor LDAP"
+
+msgid "Select the LDAP server configured for research groups synchronization."
+msgstr "Selecione o servidor LDAP configurado para sincronização de grupos de pesquisa."
+
+msgid "- Select a server -"
+msgstr "- Selecione um servidor -"
+
+msgid "No LDAP server found. Configure an LDAP server first."
+msgstr "Nenhum servidor LDAP encontrado. Configure um servidor LDAP primeiro."
+
+msgid "LDAP Query"
+msgstr "Consulta LDAP"
+
+msgid "Select the LDAP query that will be used to search for research groups."
+msgstr "Selecione a consulta LDAP que será usada para buscar grupos de pesquisa."
+
+msgid "- Select a query -"
+msgstr "- Selecione uma query -"
+
+msgid "No LDAP query found for the selected server. Create a new LDAP query."
+msgstr "Nenhuma query LDAP encontrada para o servidor selecionado. Crie uma nova query LDAP."
+
+msgid "Create New LDAP Query"
+msgstr "Criar Nova Query LDAP"
+
+msgid "Edit Selected Query"
+msgstr "Editar Query Selecionada"
+
+msgid "Attribute Mapping"
+msgstr "Mapeamento de Atributos"
+
+msgid "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."
+msgstr "Configure como os atributos LDAP são mapeados para os campos da entidade alvo.
Nota: Para mapeamentos do tipo \"Referência de Usuário\", o atributo de busca será obtido automaticamente do campo \"Atributo de nome de conta\" configurado no servidor LDAP selecionado."
+
+msgid "Entity Field"
+msgstr "Campo da Entidade"
+
+msgid "LDAP Attribute"
+msgstr "Atributo LDAP"
+
+msgid "Mapping Type"
+msgstr "Tipo de Mapeamento"
+
+msgid "Remove"
+msgstr "Remover"
+
+msgid "No mappings configured."
+msgstr "Nenhum mapeamento configurado."
+
+msgid "- Select a field -"
+msgstr "- Selecione um campo -"
+
+msgid "- Select an attribute -"
+msgstr "- Selecione um atributo -"
+
+msgid "Simple (text)"
+msgstr "Simples (texto)"
+
+msgid "User Reference (DN → User)"
+msgstr "Referência de Usuário (DN → Usuário)"
+
+msgid "Department Reference (code → Department)"
+msgstr "Referência de Departamento (código → Departamento)"
+
+msgid "Add Mapping"
+msgstr "Adicionar Mapeamento"
+
+msgid "Remove Selected"
+msgstr "Remover Selecionados"
+
+msgid "Member Synchronization"
+msgstr "Sincronização de Membros"
+
+msgid "Enable member synchronization from LDAP attribute"
+msgstr "Habilitar sincronização de membros a partir do atributo LDAP"
+
+msgid "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."
+msgstr "Quando habilitado, as associações de membros do grupo são gerenciadas com base no atributo de membros LDAP de cada entrada de grupo. Membros são adicionados ou removidos para corresponder à lista LDAP."
+
+msgid "Member LDAP attribute"
+msgstr "Atributo LDAP de membros"
+
+msgid "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."
+msgstr "Nome do atributo LDAP em cada entrada de grupo que lista seus membros (como DNs ou UIDs). Valores comuns: member, uniqueMember, memberUid. O atributo deve estar incluído na query LDAP."
+
+msgid "Group Role Mapping"
+msgstr "Mapeamento de Papéis do Grupo"
+
+msgid "Configure how user attributes map to group roles. Each role can have its own mapping criteria."
+msgstr "Configure como os atributos do usuário são mapeados para papéis do grupo. Cada papel pode ter seus próprios critérios de mapeamento."
+
+msgid "Enable role mapping"
+msgstr "Habilitar mapeamento de papéis"
+
+msgid "When enabled, users will be assigned group roles based on the mappings below."
+msgstr "Quando habilitado, os usuários receberão papéis de grupo com base nos mapeamentos abaixo."
+
+msgid "Group Role"
+msgstr "Papel do Grupo"
+
+msgid "Source"
+msgstr "Fonte"
+
+msgid "User Field / LDAP Attribute"
+msgstr "Campo do Usuário / Atributo LDAP"
+
+msgid "Group Field / Values"
+msgstr "Campo do Grupo / Valores"
+
+msgid "No role mappings configured. Users will be assigned the default role."
+msgstr "Nenhum mapeamento de papel configurado. Os usuários receberão o papel padrão."
+
+msgid "- Select a role -"
+msgstr "- Selecione um papel -"
+
+msgid "User Field"
+msgstr "Campo do Usuário"
+
+msgid "LDAP Attribute"
+msgstr "Atributo LDAP"
+
+msgid "Group Field Match"
+msgstr "Correspondência de Campo do Grupo"
+
+msgid "- Select user field -"
+msgstr "- Selecione campo do usuário -"
+
+msgid "- Select attribute/field -"
+msgstr "- Selecione atributo/campo -"
+
+msgid "- Select group field -"
+msgstr "- Selecione campo do grupo -"
+
+msgid "e.g., Director, Manager"
+msgstr "ex: Diretor, Gerente"
+
+msgid "Add Role Mapping"
+msgstr "Adicionar Mapeamento de Papel"
+
+msgid "Save Configuration"
+msgstr "Salvar Configuração"
+
+msgid "Test Connection"
+msgstr "Testar Conexão"
+
+msgid "Synchronize Research Groups"
+msgstr "Sincronizar Grupos de Pesquisa"
+
+msgid "Error loading LDAP servers: @message"
+msgstr "Erro ao carregar servidores LDAP: @message"
+
+msgid "Error loading query attributes: @message"
+msgstr "Erro ao carregar atributos da query: @message"
+
+msgid "LDAP attribute is required when field is selected."
+msgstr "O atributo LDAP é obrigatório quando um campo é selecionado."
+
+msgid "Vocabulary field is required when attribute is selected."
+msgstr "O campo vocabulário é obrigatório quando um atributo é selecionado."
+
+msgid "Configuration saved successfully."
+msgstr "Configuração salva com sucesso."
+
+msgid "Please select an LDAP server to test."
+msgstr "Por favor, selecione um servidor LDAP para testar."
+
+msgid "LDAP server \"@server\" is configured and active."
+msgstr "Servidor LDAP \"@server\" está configurado e ativo."
+
+msgid "LDAP server not found or inactive."
+msgstr "Servidor LDAP não encontrado ou inativo."
+
+msgid "Error testing connection: @message"
+msgstr "Erro ao testar conexão: @message"
+
+msgid "Please fix the errors in the form before running the synchronization."
+msgstr "Corrija os erros no formulário antes de executar a sincronização."
+
+msgid "Synchronization completed successfully. Check the logs for details."
+msgstr "Sincronização executada com sucesso. Verifique os logs para detalhes."
+
+msgid "Error during synchronization: @message"
+msgstr "Erro durante a sincronização: @message"
+
+msgid "Internal"
+msgstr "Interno"
+
+msgid "Outsider"
+msgstr "Externo"
+
+msgid "Error loading group roles: @message"
+msgstr "Erro ao carregar papéis do grupo: @message"
+
+msgid "Error loading user fields: @message"
+msgstr "Erro ao carregar campos do usuário: @message"
+
+msgid "The ldap_query module is not available. LDAP queries cannot be loaded."
+msgstr "O módulo ldap_query não está disponível. As queries LDAP não podem ser carregadas."
+
+msgid "Error loading LDAP queries: @message"
+msgstr "Erro ao carregar queries LDAP: @message"
+
+msgid "Label (group title)"
+msgstr "Rótulo (título do grupo)"
+
+msgid "Group type \"@type\" not found. Please create it first or select a different group type."
+msgstr "Tipo de grupo \"@type\" não encontrado. Crie-o primeiro ou selecione um tipo de grupo diferente."
+
+msgid "Error loading group types: @message"
+msgstr "Erro ao carregar tipos de grupo: @message"
+
+msgid "Configuration directory not found: @path"
+msgstr "Diretório de configuração não encontrado: @path"
+
+msgid "Configuration @name already exists, skipping."
+msgstr "Configuração @name já existe, pulando."
+
+msgid "Successfully imported @count configuration files."
+msgstr "Importados com sucesso @count arquivos de configuração."
+
+msgid "No new configuration files were imported. All configurations already exist."
+msgstr "Nenhum arquivo de configuração novo foi importado. Todas as configurações já existem."
+
+msgid "Error importing configuration: @message"
+msgstr "Erro ao importar configuração: @message"
diff --git a/src/Form/AccessRuleFormBase.php b/src/Form/AccessRuleFormBase.php
new file mode 100644
index 0000000..8cdc32f
--- /dev/null
+++ b/src/Form/AccessRuleFormBase.php
@@ -0,0 +1,771 @@
+configFactory = $config_factory;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->bundleInfo = $bundle_info;
+ $this->entityFieldManager = $entity_field_manager;
+ $this->routeMatch = $route_match;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('entity_type.manager'),
+ $container->get('entity_type.bundle.info'),
+ $container->get('entity_field.manager'),
+ $container->get('current_route_match')
+ );
+ }
+
+ /**
+ * Returns the config object name for the implementing module.
+ *
+ * @return string
+ * E.g. 'ldap_departments_sync.settings'.
+ */
+ abstract protected function getConfigName(): string;
+
+ /**
+ * Returns the route name for the access rules listing page.
+ *
+ * @return string
+ * E.g. 'ldap_departments_sync.access_rules'.
+ */
+ abstract protected function getAccessRulesRoute(): string;
+
+ /**
+ * Returns the default group type ID used when the config has no value set.
+ *
+ * @return string
+ * E.g. 'departments' or 'research_group'.
+ */
+ abstract protected function getDefaultGroupTypeId(): string;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state, $rule_index = 'new') {
+ $form['#prefix'] = '';
+ $form['#suffix'] = '
';
+
+ // Attach dialog library so the close button works.
+ $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
+
+ // Resolve the rule index (from route or form_state for AJAX rebuilds).
+ $resolved_index = $form_state->get('rule_index') ?? $rule_index;
+ $form_state->set('rule_index', $resolved_index);
+
+ // Load existing rule data if editing.
+ $rule = $this->loadRule($resolved_index);
+
+ // ---- Resolve current entity_type and bundle (considering AJAX changes) ----
+ $entity_type_id = $form_state->getValue('entity_type') ?? ($rule['entity_type'] ?? '');
+ $bundle = $form_state->getValue('bundle') ?? ($rule['bundle'] ?? '');
+
+ // ---- Basic settings --------------------------------------------------------
+ $form['label'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Label'),
+ '#description' => $this->t('Human-readable description of this rule.'),
+ '#default_value' => $rule['label'] ?? '',
+ '#required' => TRUE,
+ '#maxlength' => 128,
+ ];
+
+ $form['enabled'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enabled'),
+ '#default_value' => $rule['enabled'] ?? TRUE,
+ ];
+
+ // ---- Entity type -----------------------------------------------------------
+ $entity_type_options = $this->getContentEntityTypeOptions();
+
+ $form['entity_type'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Entity Type'),
+ '#options' => $entity_type_options,
+ '#empty_option' => $this->t('- Select entity type -'),
+ '#default_value' => $entity_type_id,
+ '#required' => TRUE,
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ ];
+
+ // ---- Bundle ----------------------------------------------------------------
+ $bundle_options = [];
+ if (!empty($entity_type_id)) {
+ $bundle_options = $this->getBundleOptions($entity_type_id);
+ }
+
+ $form['bundle'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Bundle'),
+ '#description' => $this->t('Leave empty to apply to all bundles of the selected entity type.'),
+ '#options' => $bundle_options,
+ '#empty_option' => $this->t('- All bundles -'),
+ '#default_value' => $bundle,
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ ];
+
+ // ---- Operations ------------------------------------------------------------
+ $form['operations'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Operations'),
+ '#options' => [
+ 'create' => $this->t('Create'),
+ 'update' => $this->t('Update'),
+ 'delete' => $this->t('Delete'),
+ 'view' => $this->t('View'),
+ ],
+ '#default_value' => $rule['operations'] ?? ['create'],
+ '#required' => TRUE,
+ ];
+
+ // ---- Mode ------------------------------------------------------------------
+ $form['mode'] = [
+ '#type' => 'radios',
+ '#title' => $this->t('Mode'),
+ '#options' => [
+ 'restrictive' => $this->t('Restrictive — deny access to users who do not match this rule'),
+ 'additive' => $this->t('Additive — only grant access; never deny other users'),
+ ],
+ '#default_value' => $rule['mode'] ?? 'restrictive',
+ ];
+
+ // ---- Membership requirements -----------------------------------------------
+ $form['membership'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Membership requirements'),
+ '#description' => $this->t(
+ 'A user is authorized if they satisfy at least one card: '
+ . 'member of that group and holding at least one of the checked roles. '
+ . 'Each card can specify a different group and different roles.'
+ ),
+ ];
+
+ $membership_count = $form_state->get('membership_count');
+
+ // Prefer form state values (preserves user edits during AJAX rebuilds).
+ // Normalize from submitted format: checkboxes return [id => id|0], we
+ // need [id, ...] for #default_value.
+ $memberships_from_state = $form_state->getValue('memberships');
+ if ($memberships_from_state !== NULL) {
+ $existing_memberships = [];
+ foreach ($memberships_from_state as $mem) {
+ $existing_memberships[] = [
+ 'group_id' => $mem['group_id'] ?? '',
+ 'roles' => is_array($mem['roles'] ?? NULL)
+ ? array_values(array_filter($mem['roles']))
+ : [],
+ ];
+ }
+ }
+ else {
+ $existing_memberships = $rule['memberships'] ?? [];
+ }
+
+ if ($membership_count === NULL) {
+ $membership_count = max(1, count($existing_memberships));
+ $form_state->set('membership_count', $membership_count);
+ }
+
+ $group_options = $this->getGroupOptions();
+ $role_options = $this->getGroupRoleOptions();
+
+ // #tree => TRUE ensures values are nested as memberships[m][group_id],
+ // memberships[m][roles], etc. Without it, all group_id fields would
+ // collide at the top-level form values.
+ $form['membership']['memberships'] = [
+ '#type' => 'container',
+ '#tree' => TRUE,
+ ];
+
+ // Render one card (container) per membership entry.
+ for ($m = 0; $m < $membership_count; $m++) {
+ $mem = $existing_memberships[$m] ?? ['group_id' => '', 'roles' => []];
+
+ $form['membership']['memberships'][$m] = [
+ '#type' => 'container',
+ '#attributes' => [
+ 'class' => ['membership-card'],
+ 'style' => 'border:1px solid #ccc;border-radius:4px;padding:12px 16px;margin-bottom:12px;background:#fafafa',
+ ],
+ ];
+
+ $form['membership']['memberships'][$m]['group_id'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Group'),
+ '#options' => $group_options,
+ '#empty_option' => $this->t('- Select a group -'),
+ '#default_value' => $mem['group_id'] ?? '',
+ '#required' => FALSE,
+ ];
+
+ // Checkboxes display the full label — no truncation.
+ $form['membership']['memberships'][$m]['roles'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Required roles (user must hold at least one)'),
+ '#options' => $role_options,
+ '#default_value' => $mem['roles'] ?? [],
+ ];
+
+ $form['membership']['memberships'][$m]['remove'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Remove this group'),
+ '#name' => 'remove_membership_' . $m,
+ '#submit' => ['::removeOneMembership'],
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [['memberships']],
+ '#attributes' => ['class' => ['button', 'button--danger', 'button--small']],
+ ];
+ }
+
+ $form['membership']['add_membership'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('+ Add group'),
+ '#submit' => ['::addMembership'],
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [['memberships']],
+ '#attributes' => ['class' => ['button']],
+ ];
+
+ // ---- Field conditions (update / delete / view only) -----------------------
+ $form['field_conditions_wrapper'] = [
+ '#type' => 'details',
+ '#title' => $this->t('Field conditions (optional — only evaluated for update/delete/view)'),
+ '#open' => !empty($rule['field_conditions']),
+ '#description' => $this->t('All conditions must be true for the rule to apply to an existing entity. For create operations conditions are ignored.'),
+ ];
+
+ $field_options = [];
+ if (!empty($entity_type_id)) {
+ $field_options = $this->getFieldOptions($entity_type_id, $bundle ?: NULL);
+ }
+
+ $cond_count = $form_state->get('condition_count');
+ if ($cond_count === NULL) {
+ $cond_count = max(1, count($rule['field_conditions'] ?? []));
+ $form_state->set('condition_count', $cond_count);
+ }
+
+ $existing_conditions = $rule['field_conditions'] ?? [];
+
+ $form['field_conditions_wrapper']['field_conditions'] = [
+ '#type' => 'table',
+ '#header' => [
+ $this->t('Field'),
+ $this->t('Value'),
+ $this->t('Remove'),
+ ],
+ ];
+
+ for ($c = 0; $c < $cond_count; $c++) {
+ $cond = $existing_conditions[$c] ?? ['field' => '', 'value' => ''];
+
+ if (!empty($field_options)) {
+ $form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [
+ '#type' => 'select',
+ '#options' => $field_options,
+ '#empty_option' => $this->t('- Select field -'),
+ '#default_value' => $cond['field'],
+ ];
+ }
+ else {
+ $form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [
+ '#type' => 'textfield',
+ '#placeholder' => $this->t('field_machine_name'),
+ '#default_value' => $cond['field'],
+ '#size' => 24,
+ ];
+ }
+
+ $form['field_conditions_wrapper']['field_conditions'][$c]['value'] = [
+ '#type' => 'textfield',
+ '#placeholder' => $this->t('Expected value'),
+ '#default_value' => $cond['value'],
+ '#size' => 24,
+ ];
+
+ $form['field_conditions_wrapper']['field_conditions'][$c]['remove'] = [
+ '#type' => 'checkbox',
+ '#default_value' => FALSE,
+ ];
+ }
+
+ $form['field_conditions_wrapper']['condition_actions'] = [
+ '#type' => 'container',
+ ];
+
+ $form['field_conditions_wrapper']['condition_actions']['add_condition'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Add condition'),
+ '#submit' => ['::addCondition'],
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [['field_conditions']],
+ ];
+
+ $form['field_conditions_wrapper']['condition_actions']['remove_conditions'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Remove selected conditions'),
+ '#submit' => ['::removeSelectedConditions'],
+ '#ajax' => [
+ 'callback' => '::updateDependentFields',
+ 'wrapper' => 'access-rule-form-wrapper',
+ 'effect' => 'fade',
+ ],
+ '#limit_validation_errors' => [['field_conditions']],
+ ];
+
+ // ---- Submit ----------------------------------------------------------------
+ $form['actions'] = ['#type' => 'actions'];
+
+ $form['actions']['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Save rule'),
+ '#button_type' => 'primary',
+ '#ajax' => [
+ 'callback' => '::ajaxSave',
+ 'wrapper' => 'access-rule-form-wrapper',
+ ],
+ ];
+
+ $form['actions']['cancel'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Cancel'),
+ '#submit' => [],
+ '#limit_validation_errors' => [],
+ '#ajax' => [
+ 'callback' => '::cancelDialog',
+ ],
+ '#attributes' => ['class' => ['button']],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * AJAX callback: replaces the entire form wrapper (entity_type/bundle/field
+ * changes and condition add/remove all reuse this single callback).
+ */
+ public function updateDependentFields(array &$form, FormStateInterface $form_state) {
+ return $form;
+ }
+
+ /**
+ * Submit handler: adds a blank membership card.
+ *
+ * Explicitly normalizes current values and appends an empty entry so the
+ * new card has no pre-filled group or roles.
+ */
+ public function addMembership(array &$form, FormStateInterface $form_state) {
+ $current = $form_state->getValue('memberships') ?? [];
+
+ // Normalize existing entries (checkboxes submit [id => id|0] format).
+ $normalized = [];
+ foreach ($current as $mem) {
+ $normalized[] = [
+ 'group_id' => $mem['group_id'] ?? '',
+ 'roles' => is_array($mem['roles'] ?? NULL)
+ ? array_values(array_filter($mem['roles']))
+ : [],
+ ];
+ }
+
+ // Append a truly empty entry for the new card.
+ $normalized[] = ['group_id' => '', 'roles' => []];
+
+ $form_state->setValue('memberships', $normalized);
+ $form_state->set('membership_count', count($normalized));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Submit handler: removes the single membership card whose button was clicked.
+ */
+ public function removeOneMembership(array &$form, FormStateInterface $form_state) {
+ $trigger = $form_state->getTriggeringElement();
+ preg_match('/remove_membership_(\d+)/', $trigger['#name'] ?? '', $matches);
+ $index = isset($matches[1]) ? (int) $matches[1] : -1;
+
+ $memberships = $form_state->getValue('memberships') ?? [];
+
+ // Normalize and remove the target entry.
+ $normalized = [];
+ foreach ($memberships as $i => $mem) {
+ if ($i === $index) {
+ continue;
+ }
+ $normalized[] = [
+ 'group_id' => $mem['group_id'] ?? '',
+ 'roles' => is_array($mem['roles'] ?? NULL)
+ ? array_values(array_filter($mem['roles']))
+ : [],
+ ];
+ }
+
+ $form_state->setValue('memberships', $normalized);
+ $form_state->set('membership_count', max(1, count($normalized)));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Submit handler: adds a blank condition row.
+ */
+ public function addCondition(array &$form, FormStateInterface $form_state) {
+ $form_state->set('condition_count', ($form_state->get('condition_count') ?? 1) + 1);
+ $form_state->setRebuild();
+ }
+
+ /**
+ * Submit handler: removes checked condition rows.
+ */
+ public function removeSelectedConditions(array &$form, FormStateInterface $form_state) {
+ $conditions = $form_state->getValue('field_conditions') ?? [];
+ $kept = array_values(array_filter($conditions, fn($c) => empty($c['remove'])));
+ $form_state->setValue('field_conditions', $kept);
+ $form_state->set('condition_count', max(1, count($kept)));
+ $form_state->setRebuild();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ $operations = array_filter($form_state->getValue('operations') ?? []);
+ if (empty($operations)) {
+ $form_state->setErrorByName('operations', $this->t('Select at least one operation.'));
+ }
+
+ $memberships = $form_state->getValue('memberships') ?? [];
+ $valid = array_filter($memberships, fn($m) => !empty($m['group_id']));
+ if (empty($valid)) {
+ $form_state->setErrorByName('memberships', $this->t('Add at least one group membership requirement.'));
+ }
+
+ foreach ($valid as $idx => $mem) {
+ if (empty($mem['roles'])) {
+ $form_state->setErrorByName(
+ "memberships][$idx][roles",
+ $this->t('Select at least one required role for each group.')
+ );
+ }
+ }
+ }
+
+ /**
+ * AJAX callback: closes the modal and redirects after a successful save.
+ *
+ * The actual save is performed by submitForm(), which Drupal always runs
+ * before the AJAX callback. This callback must NOT call saveRule() again
+ * to avoid saving the rule twice.
+ */
+ public function ajaxSave(array &$form, FormStateInterface $form_state) {
+ if ($form_state->hasAnyErrors()) {
+ // Redisplay the form with error messages.
+ return $form;
+ }
+
+ $response = new AjaxResponse();
+ $response->addCommand(new CloseModalDialogCommand());
+ $response->addCommand(new RedirectCommand(
+ Url::fromRoute($this->getAccessRulesRoute())->toString()
+ ));
+
+ return $response;
+ }
+
+ /**
+ * AJAX callback: closes the modal without saving.
+ */
+ public function cancelDialog(array &$form, FormStateInterface $form_state): AjaxResponse {
+ $response = new AjaxResponse();
+ $response->addCommand(new CloseModalDialogCommand());
+ return $response;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ // Runs for both AJAX and non-AJAX submissions.
+ $this->saveRule($form_state);
+ $form_state->setRedirectUrl(Url::fromRoute($this->getAccessRulesRoute()));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal helpers
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Loads a rule from config by index ('new' returns defaults).
+ */
+ protected function loadRule($rule_index): array {
+ if ($rule_index === 'new') {
+ return [];
+ }
+ $rules = $this->configFactory
+ ->get($this->getConfigName())
+ ->get('access_rules') ?? [];
+
+ return $rules[(int) $rule_index] ?? [];
+ }
+
+ /**
+ * Persists the rule to config.
+ */
+ protected function saveRule(FormStateInterface $form_state): void {
+ $rule_index = $form_state->get('rule_index') ?? 'new';
+
+ $operations = array_values(array_filter($form_state->getValue('operations') ?? []));
+
+ $conditions_raw = $form_state->getValue('field_conditions') ?? [];
+ $conditions = [];
+ foreach ($conditions_raw as $cond) {
+ if (!empty($cond['remove']) || empty($cond['field'])) {
+ continue;
+ }
+ $conditions[] = [
+ 'field' => $cond['field'],
+ 'value' => $cond['value'] ?? '',
+ ];
+ }
+
+ $memberships_raw = $form_state->getValue('memberships') ?? [];
+ $memberships = [];
+ foreach ($memberships_raw as $mem) {
+ if (empty($mem['group_id'])) {
+ continue;
+ }
+ // Checkboxes return [role_id => role_id] for checked, [role_id => 0] for unchecked.
+ $roles = array_values(array_filter((array) ($mem['roles'] ?? [])));
+ $memberships[] = [
+ 'group_id' => (int) $mem['group_id'],
+ 'roles' => $roles,
+ ];
+ }
+
+ $new_rule = [
+ 'label' => $form_state->getValue('label'),
+ 'entity_type' => $form_state->getValue('entity_type'),
+ 'bundle' => $form_state->getValue('bundle') ?? '',
+ 'operations' => $operations,
+ 'mode' => $form_state->getValue('mode') ?? 'restrictive',
+ 'memberships' => $memberships,
+ 'field_conditions' => $conditions,
+ 'enabled' => (bool) $form_state->getValue('enabled'),
+ ];
+
+ $config = $this->configFactory->getEditable($this->getConfigName());
+ $rules = $config->get('access_rules') ?? [];
+
+ if ($rule_index === 'new') {
+ $rules[] = $new_rule;
+ }
+ else {
+ $rules[(int) $rule_index] = $new_rule;
+ }
+
+ $config->set('access_rules', array_values($rules))->save();
+ }
+
+ /**
+ * Returns content entity type options suitable for a select element.
+ */
+ protected function getContentEntityTypeOptions(): array {
+ $options = [];
+ foreach ($this->entityTypeManager->getDefinitions() as $id => $definition) {
+ if (!($definition instanceof \Drupal\Core\Entity\ContentEntityTypeInterface)) {
+ continue;
+ }
+ // Skip internal group-related and config entity types.
+ if (in_array($id, ['group_content', 'group_relationship'], TRUE)) {
+ continue;
+ }
+ $options[$id] = (string) $definition->getLabel();
+ }
+ asort($options);
+ return $options;
+ }
+
+ /**
+ * Returns bundle options for a given entity type.
+ */
+ protected function getBundleOptions(string $entity_type_id): array {
+ $options = [];
+ try {
+ foreach ($this->bundleInfo->getBundleInfo($entity_type_id) as $bundle_id => $info) {
+ $options[$bundle_id] = (string) ($info['label'] ?? $bundle_id);
+ }
+ }
+ catch (\Exception $e) {
+ // Return empty if the entity type has no bundles.
+ }
+ asort($options);
+ return $options;
+ }
+
+ /**
+ * Returns field options for the given entity type and (optional) bundle.
+ */
+ protected function getFieldOptions(string $entity_type_id, ?string $bundle): array {
+ $options = [];
+ try {
+ $bundle = $bundle ?: $entity_type_id;
+ $definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
+ $skip = ['uuid', 'langcode', 'default_langcode', 'revision_translation_affected'];
+ foreach ($definitions as $name => $def) {
+ if (in_array($name, $skip, TRUE)) {
+ continue;
+ }
+ $options[$name] = (string) $def->getLabel() . ' (' . $name . ')';
+ }
+ }
+ catch (\Exception $e) {
+ // Return empty on error.
+ }
+ asort($options);
+ return $options;
+ }
+
+ /**
+ * Returns options for all groups of the configured group type.
+ */
+ protected function getGroupOptions(): array {
+ $options = [];
+ try {
+ $group_type_id = $this->configFactory
+ ->get($this->getConfigName())
+ ->get('group_type_id') ?: $this->getDefaultGroupTypeId();
+
+ $groups = $this->entityTypeManager
+ ->getStorage('group')
+ ->loadByProperties(['type' => $group_type_id]);
+
+ foreach ($groups as $group) {
+ $options[$group->id()] = $group->label();
+ }
+ asort($options);
+ }
+ catch (\Exception $e) {
+ // Return empty on error.
+ }
+ return $options;
+ }
+
+ /**
+ * Returns options for all roles of the configured group type.
+ */
+ protected function getGroupRoleOptions(): array {
+ $options = [];
+ try {
+ $group_type_id = $this->configFactory
+ ->get($this->getConfigName())
+ ->get('group_type_id') ?: $this->getDefaultGroupTypeId();
+
+ $roles = $this->entityTypeManager
+ ->getStorage('group_role')
+ ->loadByProperties(['group_type' => $group_type_id]);
+
+ foreach ($roles as $role) {
+ $scope_labels = [
+ 'individual' => $this->t('Individual'),
+ 'insider' => $this->t('Insider'),
+ 'outsider' => $this->t('Outsider'),
+ ];
+ $scope = $role->getScope();
+ $suffix = isset($scope_labels[$scope]) ? ' [' . $scope_labels[$scope] . ']' : '';
+ $options[$role->id()] = $role->label() . $suffix;
+ }
+ }
+ catch (\Exception $e) {
+ // Return empty on error.
+ }
+ return $options;
+ }
+
+}
diff --git a/src/Form/AccessRulesFormBase.php b/src/Form/AccessRulesFormBase.php
new file mode 100644
index 0000000..d891eba
--- /dev/null
+++ b/src/Form/AccessRulesFormBase.php
@@ -0,0 +1,221 @@
+entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('config.typed'),
+ $container->get('entity_type.manager')
+ );
+ }
+
+ /**
+ * Returns the config object name for the implementing module.
+ *
+ * @return string
+ * E.g. 'ldap_departments_sync.settings'.
+ */
+ abstract protected function getConfigName(): string;
+
+ /**
+ * Returns the route name for the single-rule edit/create form.
+ *
+ * @return string
+ * E.g. 'ldap_departments_sync.access_rule_form'.
+ */
+ abstract protected function getAccessRuleFormRoute(): string;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return [$this->getConfigName()];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
+
+ $form['description'] = [
+ '#type' => 'markup',
+ '#markup' => '' . $this->t(
+ 'Define rules that restrict or grant entity operations based on group membership.
'
+ . 'Restrictive: if a matching rule exists and the user does not satisfy it, access is denied.
'
+ . 'Additive: the rule only grants extra access; other users keep their default permissions.
'
+ . 'Field conditions are only evaluated for update/delete/view (entity already exists).'
+ ) . '
',
+ ];
+
+ $existing_rules = $this->config($this->getConfigName())->get('access_rules') ?? [];
+
+ $form['summary'] = [
+ '#type' => 'table',
+ '#header' => [
+ $this->t('Label'),
+ $this->t('Entity type / Bundle'),
+ $this->t('Operations'),
+ $this->t('Groups / Roles'),
+ $this->t('Mode'),
+ $this->t('Status'),
+ $this->t('Edit'),
+ $this->t('Remove'),
+ ],
+ '#empty' => $this->t('No access rules defined. Click "Add Rule" to create the first one.'),
+ '#attributes' => ['class' => ['access-rules-summary-table']],
+ ];
+
+ foreach ($existing_rules as $i => $rule) {
+ $bundle_part = !empty($rule['bundle'])
+ ? ' / ' . $rule['bundle'] . ''
+ : ' / ' . $this->t('all bundles') . '';
+
+ $form['summary'][$i]['label'] = [
+ '#markup' => '' . ($rule['label'] ?: $this->t('(no label)')) . '',
+ ];
+
+ $form['summary'][$i]['entity_bundle'] = [
+ '#markup' => ($rule['entity_type'] ?? '?') . $bundle_part,
+ ];
+
+ $form['summary'][$i]['operations'] = [
+ '#markup' => implode(', ', $rule['operations'] ?? []),
+ ];
+
+ $membership_lines = [];
+ foreach ($rule['memberships'] ?? [] as $mem) {
+ try {
+ $group = $this->entityTypeManager->getStorage('group')->load($mem['group_id'] ?? 0);
+ $group_label = $group ? $group->label() : '#' . ($mem['group_id'] ?? '?');
+ }
+ catch (\Exception $e) {
+ $group_label = '#' . ($mem['group_id'] ?? '?');
+ }
+ $roles = implode(', ', $mem['roles'] ?? []);
+ $membership_lines[] = $group_label . ($roles ? ' (' . $roles . ')' : '');
+ }
+ $form['summary'][$i]['memberships_summary'] = [
+ '#markup' => $membership_lines
+ ? implode('
', $membership_lines)
+ : '' . $this->t('none') . '',
+ ];
+
+ $form['summary'][$i]['mode'] = [
+ '#markup' => ($rule['mode'] ?? 'restrictive') === 'additive'
+ ? $this->t('Additive')
+ : $this->t('Restrictive'),
+ ];
+
+ $form['summary'][$i]['enabled'] = [
+ '#markup' => ($rule['enabled'] ?? TRUE)
+ ? '✓ ' . $this->t('On') . ''
+ : '✗ ' . $this->t('Off') . '',
+ ];
+
+ $form['summary'][$i]['edit'] = [
+ '#type' => 'link',
+ '#title' => $this->t('Edit'),
+ '#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => $i]),
+ '#attributes' => [
+ 'class' => ['use-ajax', 'button', 'button--small'],
+ 'data-dialog-type' => 'modal',
+ 'data-dialog-options' => json_encode([
+ 'width' => 860,
+ 'title' => $this->t('Edit Access Rule'),
+ ]),
+ ],
+ ];
+
+ $form['summary'][$i]['remove'] = [
+ '#type' => 'checkbox',
+ '#default_value' => FALSE,
+ ];
+ }
+
+ $form['actions'] = ['#type' => 'actions'];
+
+ $form['actions']['add_rule'] = [
+ '#type' => 'link',
+ '#title' => $this->t('Add Rule'),
+ '#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => 'new']),
+ '#attributes' => [
+ 'class' => ['use-ajax', 'button', 'button--primary'],
+ 'data-dialog-type' => 'modal',
+ 'data-dialog-options' => json_encode([
+ 'width' => 860,
+ 'title' => $this->t('New Access Rule'),
+ ]),
+ ],
+ ];
+
+ $form['actions']['remove_selected'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Remove Selected'),
+ '#limit_validation_errors' => [['summary']],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $summary_values = $form_state->getValue('summary') ?? [];
+ $config = $this->configFactory()->getEditable($this->getConfigName());
+ $existing_rules = $config->get('access_rules') ?? [];
+
+ $kept = [];
+ foreach ($existing_rules as $i => $rule) {
+ if (empty($summary_values[$i]['remove'])) {
+ $kept[] = $rule;
+ }
+ }
+
+ $config->set('access_rules', array_values($kept))->save();
+
+ $this->messenger()->addStatus($this->t('Access rules updated.'));
+ }
+
+}
diff --git a/src/GroupAccessRulesService.php b/src/GroupAccessRulesService.php
new file mode 100644
index 0000000..7034d42
--- /dev/null
+++ b/src/GroupAccessRulesService.php
@@ -0,0 +1,276 @@
+configFactory = $config_factory;
+ $this->entityTypeManager = $entity_type_manager;
+ $this->configName = $configName;
+ }
+
+ /**
+ * Check access for an operation on an existing entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity being accessed.
+ * @param string $operation
+ * The operation: view, update, delete.
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The user account.
+ *
+ * @return \Drupal\Core\Access\AccessResult
+ * The access result.
+ */
+ public function checkAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
+ $rules = $this->getRulesForOperation(
+ $entity->getEntityTypeId(),
+ $entity->bundle(),
+ $operation
+ );
+
+ if (empty($rules)) {
+ return AccessResult::neutral();
+ }
+
+ $cache = (new CacheableMetadata())
+ ->addCacheContexts(['user'])
+ ->addCacheableDependency($entity)
+ ->addCacheTags(['config:' . $this->configName]);
+
+ foreach ($rules as $rule) {
+ // For update/delete/view, check optional field conditions on the entity.
+ if (!$this->entityMatchesConditions($entity, $rule['field_conditions'] ?? [])) {
+ continue;
+ }
+
+ if ($this->userMatchesRule($account, $rule)) {
+ return AccessResult::allowed()->addCacheableDependency($cache);
+ }
+
+ if (($rule['mode'] ?? 'restrictive') === 'restrictive') {
+ return AccessResult::forbidden()->addCacheableDependency($cache);
+ }
+ }
+
+ return AccessResult::neutral()->addCacheableDependency($cache);
+ }
+
+ /**
+ * Check create access for an entity type and bundle.
+ *
+ * Field conditions are not evaluated here because the entity does not yet
+ * exist at creation time.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The user account.
+ * @param string $entity_type
+ * The entity type ID.
+ * @param string $bundle
+ * The bundle.
+ *
+ * @return \Drupal\Core\Access\AccessResult
+ * The access result.
+ */
+ public function checkCreateAccess(AccountInterface $account, string $entity_type, string $bundle): AccessResult {
+ $rules = $this->getRulesForOperation($entity_type, $bundle, 'create');
+
+ if (empty($rules)) {
+ return AccessResult::neutral();
+ }
+
+ $cache = (new CacheableMetadata())
+ ->addCacheContexts(['user'])
+ ->addCacheTags(['config:' . $this->configName]);
+
+ foreach ($rules as $rule) {
+ if ($this->userMatchesRule($account, $rule)) {
+ return AccessResult::allowed()->addCacheableDependency($cache);
+ }
+
+ if (($rule['mode'] ?? 'restrictive') === 'restrictive') {
+ return AccessResult::forbidden()->addCacheableDependency($cache);
+ }
+ }
+
+ return AccessResult::neutral()->addCacheableDependency($cache);
+ }
+
+ /**
+ * Returns enabled rules that match the given entity type, bundle, and operation.
+ *
+ * @param string $entity_type
+ * Entity type ID.
+ * @param string $bundle
+ * Bundle machine name.
+ * @param string $operation
+ * Operation name (create, update, delete, view).
+ *
+ * @return array
+ * Matching rules.
+ */
+ protected function getRulesForOperation(string $entity_type, string $bundle, string $operation): array {
+ $all_rules = $this->configFactory
+ ->get($this->configName)
+ ->get('access_rules') ?? [];
+
+ return array_values(array_filter($all_rules, function (array $rule) use ($entity_type, $bundle, $operation) {
+ if (!($rule['enabled'] ?? TRUE)) {
+ return FALSE;
+ }
+ if (($rule['entity_type'] ?? '') !== $entity_type) {
+ return FALSE;
+ }
+ // An empty bundle in the rule means "all bundles of this entity type".
+ if (!empty($rule['bundle']) && $rule['bundle'] !== $bundle) {
+ return FALSE;
+ }
+ if (!in_array($operation, $rule['operations'] ?? [], TRUE)) {
+ return FALSE;
+ }
+ return TRUE;
+ }));
+ }
+
+ /**
+ * Returns TRUE if the entity satisfies all field conditions of a rule.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ * The entity to check.
+ * @param array $conditions
+ * Array of conditions, each with 'field' and 'value' keys.
+ *
+ * @return bool
+ * TRUE if all conditions are met or there are no conditions.
+ */
+ protected function entityMatchesConditions(EntityInterface $entity, array $conditions): bool {
+ foreach ($conditions as $condition) {
+ $field_name = $condition['field'] ?? '';
+ $expected = (string) ($condition['value'] ?? '');
+
+ if (empty($field_name) || !$entity->hasField($field_name)) {
+ return FALSE;
+ }
+
+ $field = $entity->get($field_name);
+ // Support simple value fields and entity reference fields.
+ $actual = (string) ($field->value ?? $field->target_id ?? '');
+
+ if ($actual !== $expected) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+ }
+
+ /**
+ * Returns TRUE if the account satisfies the membership requirements of a rule.
+ *
+ * Each rule contains a `memberships` array where every entry pairs a group ID
+ * with its own set of required roles. A user is authorized if they match
+ * at least one entry: member of that group AND holding at least one of the
+ * entry's roles.
+ *
+ * @param \Drupal\Core\Session\AccountInterface $account
+ * The account to check.
+ * @param array $rule
+ * The access rule configuration.
+ *
+ * @return bool
+ * TRUE if the user satisfies at least one membership+role requirement.
+ */
+ protected function userMatchesRule(AccountInterface $account, array $rule): bool {
+ if (!$account->isAuthenticated()) {
+ return FALSE;
+ }
+
+ $memberships = $rule['memberships'] ?? [];
+ if (empty($memberships)) {
+ return FALSE;
+ }
+
+ try {
+ $storage = $this->entityTypeManager->getStorage('group');
+ }
+ catch (\Exception $e) {
+ return FALSE;
+ }
+
+ foreach ($memberships as $entry) {
+ $group_id = (int) ($entry['group_id'] ?? 0);
+ $required_roles = $entry['roles'] ?? [];
+
+ if (!$group_id || empty($required_roles)) {
+ continue;
+ }
+
+ $group = $storage->load($group_id);
+ if (!$group) {
+ continue;
+ }
+
+ $membership = $group->getMember($account);
+ if (!$membership) {
+ continue;
+ }
+
+ $member_role_ids = array_keys($membership->getRoles());
+ foreach ($required_roles as $required_role) {
+ if (in_array($required_role, $member_role_ids, TRUE)) {
+ return TRUE;
+ }
+ }
+ }
+
+ return FALSE;
+ }
+
+}
diff --git a/translations/ldap_groups_sync.pt-br.po b/translations/ldap_groups_sync.pt-br.po
new file mode 100644
index 0000000..0a5a989
--- /dev/null
+++ b/translations/ldap_groups_sync.pt-br.po
@@ -0,0 +1,193 @@
+# Portuguese (Brazil) translation for LDAP Groups Sync base module
+# Copyright (c) 2026
+# This file is distributed under the same license as the LDAP Groups Sync module.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: LDAP Groups Sync 1.0.0\n"
+"POT-Creation-Date: 2026-02-28 00:00+0000\n"
+"PO-Revision-Date: 2026-02-28 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"
+
+#: src/Form/AccessRulesFormBase.php
+
+msgid "Define rules that restrict or grant entity operations based on group membership.
Restrictive: if a matching rule exists and the user does not satisfy it, access is denied.
Additive: the rule only grants extra access; other users keep their default permissions.
Field conditions are only evaluated for update/delete/view (entity already exists)."
+msgstr "Defina regras que restringem ou concedem operações em entidades com base em membros de grupo.
Restritiva: se uma regra correspondente existir e o usuário não a satisfizer, o acesso é negado.
Aditiva: a regra apenas concede acesso extra; outros usuários mantêm suas permissões padrão.
Condições de campo são avaliadas apenas para atualizar/excluir/visualizar (entidade já existe)."
+
+msgid "Label"
+msgstr "Rótulo"
+
+msgid "Entity type / Bundle"
+msgstr "Tipo de entidade / Bundle"
+
+msgid "Operations"
+msgstr "Operações"
+
+msgid "Groups / Roles"
+msgstr "Grupos / Papéis"
+
+msgid "Mode"
+msgstr "Modo"
+
+msgid "Status"
+msgstr "Status"
+
+msgid "Edit"
+msgstr "Editar"
+
+msgid "Remove"
+msgstr "Remover"
+
+msgid "No access rules defined. Click \"Add Rule\" to create the first one."
+msgstr "Nenhuma regra de acesso definida. Clique em \"Adicionar Regra\" para criar a primeira."
+
+msgid "all bundles"
+msgstr "todos os bundles"
+
+msgid "(no label)"
+msgstr "(sem rótulo)"
+
+msgid "none"
+msgstr "nenhum"
+
+msgid "Additive"
+msgstr "Aditivo"
+
+msgid "Restrictive"
+msgstr "Restritivo"
+
+msgid "On"
+msgstr "Ativo"
+
+msgid "Off"
+msgstr "Inativo"
+
+msgid "Edit Access Rule"
+msgstr "Editar Regra de Acesso"
+
+msgid "Add Rule"
+msgstr "Adicionar Regra"
+
+msgid "New Access Rule"
+msgstr "Nova Regra de Acesso"
+
+msgid "Remove Selected"
+msgstr "Remover Selecionados"
+
+msgid "Access rules updated."
+msgstr "Regras de acesso atualizadas."
+
+#: src/Form/AccessRuleFormBase.php
+
+msgid "Human-readable description of this rule."
+msgstr "Descrição legível desta regra."
+
+msgid "Enabled"
+msgstr "Habilitado"
+
+msgid "Entity Type"
+msgstr "Tipo de Entidade"
+
+msgid "- Select entity type -"
+msgstr "- Selecione o tipo de entidade -"
+
+msgid "Bundle"
+msgstr "Bundle"
+
+msgid "Leave empty to apply to all bundles of the selected entity type."
+msgstr "Deixe vazio para aplicar a todos os bundles do tipo de entidade selecionado."
+
+msgid "- All bundles -"
+msgstr "- Todos os bundles -"
+
+msgid "Create"
+msgstr "Criar"
+
+msgid "Update"
+msgstr "Atualizar"
+
+msgid "Delete"
+msgstr "Excluir"
+
+msgid "View"
+msgstr "Visualizar"
+
+msgid "Restrictive — deny access to users who do not match this rule"
+msgstr "Restritivo — nega acesso a usuários que não correspondem a esta regra"
+
+msgid "Additive — only grant access; never deny other users"
+msgstr "Aditivo — apenas concede acesso; nunca nega outros usuários"
+
+msgid "Membership requirements"
+msgstr "Requisitos de associação"
+
+msgid "A user is authorized if they satisfy at least one card: member of that group and holding at least one of the checked roles. Each card can specify a different group and different roles."
+msgstr "Um usuário é autorizado se satisfizer pelo menos um cartão: membro daquele grupo e possuindo ao menos um dos papéis marcados. Cada cartão pode especificar um grupo e papéis diferentes."
+
+msgid "Group"
+msgstr "Grupo"
+
+msgid "- Select a group -"
+msgstr "- Selecione um grupo -"
+
+msgid "Required roles (user must hold at least one)"
+msgstr "Papéis obrigatórios (o usuário deve ter pelo menos um)"
+
+msgid "Remove this group"
+msgstr "Remover este grupo"
+
+msgid "+ Add group"
+msgstr "+ Adicionar grupo"
+
+msgid "Field conditions (optional — only evaluated for update/delete/view)"
+msgstr "Condições de campo (opcional — avaliadas apenas para atualizar/excluir/visualizar)"
+
+msgid "All conditions must be true for the rule to apply to an existing entity. For create operations conditions are ignored."
+msgstr "Todas as condições devem ser verdadeiras para que a regra se aplique a uma entidade existente. Para operações de criação, as condições são ignoradas."
+
+msgid "Field"
+msgstr "Campo"
+
+msgid "Value"
+msgstr "Valor"
+
+msgid "- Select field -"
+msgstr "- Selecione o campo -"
+
+msgid "field_machine_name"
+msgstr "field_machine_name"
+
+msgid "Expected value"
+msgstr "Valor esperado"
+
+msgid "Add condition"
+msgstr "Adicionar condição"
+
+msgid "Remove selected conditions"
+msgstr "Remover condições selecionadas"
+
+msgid "Save rule"
+msgstr "Salvar regra"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "Select at least one operation."
+msgstr "Selecione pelo menos uma operação."
+
+msgid "Add at least one group membership requirement."
+msgstr "Adicione pelo menos um requisito de associação ao grupo."
+
+msgid "Select at least one required role for each group."
+msgstr "Selecione pelo menos um papel obrigatório para cada grupo."
+
+msgid "Individual"
+msgstr "Individual"
+
+msgid "Insider"
+msgstr "Interno"