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