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; } }