permission_forge 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1138 -0
- data/LICENSE.txt +21 -0
- data/README.md +356 -0
- data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
- data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
- data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
- data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
- data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
- data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
- data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
- data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
- data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
- data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
- data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
- data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
- data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
- data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
- data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
- data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
- data/app/controllers/permission_forge/api/base_controller.rb +220 -0
- data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
- data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
- data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
- data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
- data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
- data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
- data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
- data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
- data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
- data/app/controllers/permission_forge/application_controller.rb +198 -0
- data/app/controllers/permission_forge/assets_controller.rb +97 -0
- data/app/controllers/permission_forge/assignments_controller.rb +266 -0
- data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
- data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
- data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
- data/app/controllers/permission_forge/home_controller.rb +24 -0
- data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
- data/app/controllers/permission_forge/people_controller.rb +190 -0
- data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
- data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
- data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
- data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
- data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
- data/app/controllers/permission_forge/preferences_controller.rb +26 -0
- data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
- data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
- data/app/controllers/permission_forge/resources_controller.rb +189 -0
- data/app/controllers/permission_forge/settings_controller.rb +33 -0
- data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
- data/app/frontend/permission_forge/brand/chevron.svg +1 -0
- data/app/frontend/permission_forge/brand/favicon.ico +0 -0
- data/app/frontend/permission_forge/brand/favicon.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
- data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
- data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
- data/app/frontend/permission_forge/brand/mark.svg +1 -0
- data/app/frontend/permission_forge/pf_admin.css +766 -0
- data/app/frontend/permission_forge/pf_admin.js +482 -0
- data/app/helpers/permission_forge/application_helper.rb +167 -0
- data/app/helpers/permission_forge/audit_helper.rb +230 -0
- data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
- data/app/helpers/permission_forge/badges_helper.rb +134 -0
- data/app/helpers/permission_forge/changes_helper.rb +117 -0
- data/app/helpers/permission_forge/people_helper.rb +118 -0
- data/app/helpers/permission_forge/sentences_helper.rb +175 -0
- data/app/helpers/permission_forge/settings_helper.rb +152 -0
- data/app/helpers/permission_forge/ui_helper.rb +184 -0
- data/app/models/permission_forge/audit_event.rb +159 -0
- data/app/models/permission_forge/permission_set.rb +100 -0
- data/app/models/permission_forge/permission_set_group.rb +83 -0
- data/app/models/permission_forge/permission_set_group_user.rb +43 -0
- data/app/models/permission_forge/permission_set_rule.rb +99 -0
- data/app/models/permission_forge/permission_set_user.rb +158 -0
- data/app/models/permission_forge/resource.rb +100 -0
- data/app/models/permission_forge/resource_action.rb +58 -0
- data/app/models/permission_forge/resource_field.rb +69 -0
- data/app/views/layouts/permission_forge/application.html.erb +30 -0
- data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
- data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
- data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
- data/app/views/permission_forge/assignments/new.html.erb +99 -0
- data/app/views/permission_forge/audit_events/index.html.erb +161 -0
- data/app/views/permission_forge/audit_events/show.html.erb +146 -0
- data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
- data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
- data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
- data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
- data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
- data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
- data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
- data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
- data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
- data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
- data/app/views/permission_forge/home/index.html.erb +69 -0
- data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
- data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
- data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
- data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
- data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
- data/app/views/permission_forge/people/_why_item.html.erb +19 -0
- data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
- data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
- data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
- data/app/views/permission_forge/people/index.html.erb +78 -0
- data/app/views/permission_forge/people/show.html.erb +54 -0
- data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
- data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
- data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
- data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
- data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
- data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
- data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
- data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
- data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
- data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
- data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
- data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
- data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
- data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
- data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
- data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
- data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
- data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
- data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
- data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
- data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
- data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
- data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
- data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
- data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
- data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
- data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
- data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
- data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
- data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
- data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
- data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
- data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
- data/app/views/permission_forge/resources/_sync.html.erb +57 -0
- data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
- data/app/views/permission_forge/resources/new.html.erb +57 -0
- data/app/views/permission_forge/resources/show.html.erb +117 -0
- data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
- data/app/views/permission_forge/settings/_row.html.erb +38 -0
- data/app/views/permission_forge/settings/show.html.erb +67 -0
- data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
- data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
- data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
- data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
- data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
- data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
- data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
- data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
- data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
- data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
- data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
- data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
- data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
- data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
- data/app/views/permission_forge/shared/_nav.html.erb +12 -0
- data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
- data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
- data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
- data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
- data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
- data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
- data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
- data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
- data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
- data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
- data/config/locales/permission_forge.en.yml +2115 -0
- data/config/locales/permission_forge.mk.yml +2166 -0
- data/config/locales/permission_forge.sq.yml +2164 -0
- data/config/routes.rb +143 -0
- data/lib/generators/permission_forge/install/install_generator.rb +105 -0
- data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
- data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
- data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
- data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
- data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
- data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
- data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
- data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
- data/lib/permission_forge/admin/action_across_sets.rb +76 -0
- data/lib/permission_forge/admin/action_rows.rb +156 -0
- data/lib/permission_forge/admin/attention_items.rb +207 -0
- data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
- data/lib/permission_forge/admin/audit_log.rb +193 -0
- data/lib/permission_forge/admin/audit_log_days.rb +41 -0
- data/lib/permission_forge/admin/audit_lookups.rb +105 -0
- data/lib/permission_forge/admin/audit_name_history.rb +85 -0
- data/lib/permission_forge/admin/audit_trail.rb +169 -0
- data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
- data/lib/permission_forge/admin/change_set.rb +123 -0
- data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
- data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
- data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
- data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
- data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
- data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
- data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
- data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
- data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
- data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
- data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
- data/lib/permission_forge/admin/column_fields.rb +48 -0
- data/lib/permission_forge/admin/conditions_form.rb +78 -0
- data/lib/permission_forge/admin/controller_catalog.rb +166 -0
- data/lib/permission_forge/admin/csv.rb +41 -0
- data/lib/permission_forge/admin/editable_lock.rb +157 -0
- data/lib/permission_forge/admin/effective_access.rb +158 -0
- data/lib/permission_forge/admin/effective_fields.rb +176 -0
- data/lib/permission_forge/admin/expiry_presets.rb +51 -0
- data/lib/permission_forge/admin/exports/document.rb +89 -0
- data/lib/permission_forge/admin/exports/events.rb +51 -0
- data/lib/permission_forge/admin/exports/holdings.rb +118 -0
- data/lib/permission_forge/admin/exports.rb +37 -0
- data/lib/permission_forge/admin/field_across_sets.rb +51 -0
- data/lib/permission_forge/admin/field_detail.rb +112 -0
- data/lib/permission_forge/admin/field_level.rb +74 -0
- data/lib/permission_forge/admin/field_rows.rb +137 -0
- data/lib/permission_forge/admin/last_changes.rb +94 -0
- data/lib/permission_forge/admin/model_catalog.rb +216 -0
- data/lib/permission_forge/admin/people_candidates.rb +108 -0
- data/lib/permission_forge/admin/people_directory.rb +158 -0
- data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
- data/lib/permission_forge/admin/person_label.rb +95 -0
- data/lib/permission_forge/admin/record_id.rb +19 -0
- data/lib/permission_forge/admin/rule_builder.rb +58 -0
- data/lib/permission_forge/admin/rule_form.rb +175 -0
- data/lib/permission_forge/admin/rule_form_input.rb +74 -0
- data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
- data/lib/permission_forge/admin/settings_catalog.rb +261 -0
- data/lib/permission_forge/admin/settings_values.rb +130 -0
- data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
- data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
- data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
- data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
- data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
- data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
- data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
- data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
- data/lib/permission_forge/audit/actor.rb +144 -0
- data/lib/permission_forge/audit/actor_resolver.rb +103 -0
- data/lib/permission_forge/audit/audited_views.rb +208 -0
- data/lib/permission_forge/audit/boot_warnings.rb +58 -0
- data/lib/permission_forge/audit/column_values.rb +56 -0
- data/lib/permission_forge/audit/context.rb +98 -0
- data/lib/permission_forge/audit/detached.rb +62 -0
- data/lib/permission_forge/audit/event_catalog.rb +110 -0
- data/lib/permission_forge/audit/event_query.rb +192 -0
- data/lib/permission_forge/audit/failure.rb +47 -0
- data/lib/permission_forge/audit/field_access.rb +186 -0
- data/lib/permission_forge/audit/gate.rb +167 -0
- data/lib/permission_forge/audit/job_context.rb +32 -0
- data/lib/permission_forge/audit/log_access.rb +36 -0
- data/lib/permission_forge/audit/recorder.rb +168 -0
- data/lib/permission_forge/audit/refusals.rb +61 -0
- data/lib/permission_forge/audit/request_context.rb +152 -0
- data/lib/permission_forge/audit/runtime.rb +178 -0
- data/lib/permission_forge/audit/runtime_emission.rb +135 -0
- data/lib/permission_forge/audit/settings_observer.rb +70 -0
- data/lib/permission_forge/audit/source.rb +54 -0
- data/lib/permission_forge/audit/subscribers/base.rb +69 -0
- data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
- data/lib/permission_forge/audit/subscribers/log.rb +45 -0
- data/lib/permission_forge/audit/subscribers.rb +58 -0
- data/lib/permission_forge/audit.rb +302 -0
- data/lib/permission_forge/boot_checks.rb +184 -0
- data/lib/permission_forge/cache/backlog.rb +142 -0
- data/lib/permission_forge/cache/base.rb +200 -0
- data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
- data/lib/permission_forge/cache/failure.rb +171 -0
- data/lib/permission_forge/cache/handled_errors.rb +82 -0
- data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
- data/lib/permission_forge/cache/invalidation.rb +180 -0
- data/lib/permission_forge/cache/memory_cache.rb +77 -0
- data/lib/permission_forge/cache/null_cache.rb +40 -0
- data/lib/permission_forge/cache/rails_cache.rb +231 -0
- data/lib/permission_forge/cache/rebuild.rb +149 -0
- data/lib/permission_forge/cache/settlement.rb +63 -0
- data/lib/permission_forge/cache/user_keys.rb +206 -0
- data/lib/permission_forge/cache/variant_removal.rb +55 -0
- data/lib/permission_forge/cache/withheld_notice.rb +60 -0
- data/lib/permission_forge/concerns/audited_collection.rb +34 -0
- data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
- data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
- data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
- data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
- data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
- data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
- data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
- data/lib/permission_forge/concerns/has_uuid.rb +24 -0
- data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
- data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
- data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
- data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
- data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
- data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
- data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
- data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
- data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
- data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
- data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
- data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
- data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
- data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
- data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
- data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
- data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
- data/lib/permission_forge/conditional_sets.rb +361 -0
- data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
- data/lib/permission_forge/configuration/audit_settings.rb +184 -0
- data/lib/permission_forge/configuration/cache_settings.rb +76 -0
- data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
- data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
- data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
- data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
- data/lib/permission_forge/configuration/presets.rb +139 -0
- data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
- data/lib/permission_forge/configuration/strategy_values.rb +57 -0
- data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
- data/lib/permission_forge/configuration.rb +624 -0
- data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
- data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
- data/lib/permission_forge/controller_actions/mapping.rb +167 -0
- data/lib/permission_forge/controller_actions.rb +122 -0
- data/lib/permission_forge/deprecation.rb +86 -0
- data/lib/permission_forge/engine.rb +113 -0
- data/lib/permission_forge/execution_state.rb +318 -0
- data/lib/permission_forge/invalidation_batch.rb +206 -0
- data/lib/permission_forge/masked_models.rb +226 -0
- data/lib/permission_forge/masking_sync.rb +271 -0
- data/lib/permission_forge/model_bindings.rb +90 -0
- data/lib/permission_forge/open_transaction.rb +303 -0
- data/lib/permission_forge/operators/constants.rb +41 -0
- data/lib/permission_forge/operators/registry.rb +300 -0
- data/lib/permission_forge/operators/rule_column.rb +120 -0
- data/lib/permission_forge/operators/validations.rb +111 -0
- data/lib/permission_forge/operators/value_casters/number.rb +68 -0
- data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
- data/lib/permission_forge/operators/value_casters.rb +80 -0
- data/lib/permission_forge/operators/value_typing.rb +115 -0
- data/lib/permission_forge/query_scoping_warnings.rb +68 -0
- data/lib/permission_forge/rake_audit.rb +65 -0
- data/lib/permission_forge/resource_chain.rb +190 -0
- data/lib/permission_forge/rls.rb +209 -0
- data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
- data/lib/permission_forge/services/audit_event_purger.rb +150 -0
- data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
- data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
- data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
- data/lib/permission_forge/services/concerns/clonable.rb +29 -0
- data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
- data/lib/permission_forge/services/dashboard_stats.rb +43 -0
- data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
- data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
- data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
- data/lib/permission_forge/services/field_permission_merger.rb +83 -0
- data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
- data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
- data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
- data/lib/permission_forge/services/host_key_guard.rb +110 -0
- data/lib/permission_forge/services/mask_value.rb +93 -0
- data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
- data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
- data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
- data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
- data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
- data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
- data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
- data/lib/permission_forge/services/permits_attributes.rb +270 -0
- data/lib/permission_forge/services/resource_field_generator.rb +93 -0
- data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
- data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
- data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
- data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
- data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
- data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
- data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
- data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
- data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
- data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
- data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
- data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
- data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
- data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
- data/lib/permission_forge/services/security_audit.rb +75 -0
- data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
- data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
- data/lib/permission_forge/version.rb +3 -0
- data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
- data/lib/permission_forge/virtual_fields.rb +102 -0
- data/lib/permission_forge.rb +464 -0
- data/lib/tasks/permission_forge.rake +31 -0
- data/lib/tasks/permission_forge_audit_events.rake +28 -0
- data/lib/tasks/permission_forge_cache.rake +26 -0
- data/lib/tasks/permission_forge_expiration.rake +30 -0
- data/lib/tasks/permission_forge_gdpr.rake +69 -0
- data/lib/tasks/permission_forge_rls.rake +38 -0
- metadata +515 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PermissionForge admin UI — progressive enhancement only.
|
|
3
|
+
*
|
|
4
|
+
* Every flow works without this file: drawers and dialogs are real pages,
|
|
5
|
+
* filters are links, forms post normally. This script only makes them
|
|
6
|
+
* smoother (load pages into <dialog>, live search, change counters).
|
|
7
|
+
* No globals, no inline handlers (CSP-safe); behaviour hangs off data-pf-*.
|
|
8
|
+
*/
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const root = document.documentElement;
|
|
13
|
+
if (!root.classList.contains('pf-root')) return;
|
|
14
|
+
root.classList.add('pf-js');
|
|
15
|
+
|
|
16
|
+
const FRAGMENT_HEADER = 'X-PF-Fragment';
|
|
17
|
+
|
|
18
|
+
// Replaces an element's content with a server-rendered fragment, using DOM
|
|
19
|
+
// calls rather than an HTML-string property. This is not a sanitiser:
|
|
20
|
+
// DOMParser keeps event-handler attributes and javascript: URLs just as the
|
|
21
|
+
// HTML property would. What keeps them out is the server escaping every value
|
|
22
|
+
// and the host's strict CSP; <script> elements parsed here never run.
|
|
23
|
+
function setContent(el, html) {
|
|
24
|
+
if (html === '' || html == null) { el.replaceChildren(); return; }
|
|
25
|
+
el.replaceChildren(...new DOMParser().parseFromString(html, 'text/html').body.childNodes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
|
|
29
|
+
function $$(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); }
|
|
30
|
+
function on(type, sel, fn) {
|
|
31
|
+
document.addEventListener(type, function (e) {
|
|
32
|
+
const el = e.target && e.target.closest ? e.target.closest(sel) : null;
|
|
33
|
+
if (el) fn(e, el);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function debounce(fn, ms) {
|
|
37
|
+
let t;
|
|
38
|
+
return function () { const a = arguments, self = this; clearTimeout(t); t = setTimeout(function () { fn.apply(self, a); }, ms); };
|
|
39
|
+
}
|
|
40
|
+
function fmt(template, count) { return (template || '').replace('%{count}', count); }
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------ dialogs and drawers */
|
|
43
|
+
let lastTrigger = null;
|
|
44
|
+
|
|
45
|
+
function modalElement(kind) {
|
|
46
|
+
const id = kind === 'drawer' ? 'pf-drawer' : 'pf-dialog';
|
|
47
|
+
let el = document.getElementById(id);
|
|
48
|
+
if (!el) {
|
|
49
|
+
el = document.createElement('dialog');
|
|
50
|
+
el.id = id;
|
|
51
|
+
el.className = kind === 'drawer' ? 'pf-drawer' : 'pf-dialog';
|
|
52
|
+
el.setAttribute('aria-modal', 'true');
|
|
53
|
+
document.body.appendChild(el);
|
|
54
|
+
el.addEventListener('click', function (e) { if (e.target === el) el.close(); });
|
|
55
|
+
// The close event is dispatched asynchronously: if the dialog was
|
|
56
|
+
// reopened with fresh content in the meantime (in-place refresh), keep it.
|
|
57
|
+
el.addEventListener('close', function () {
|
|
58
|
+
if (el.open) return;
|
|
59
|
+
setContent(el, '');
|
|
60
|
+
if (lastTrigger && document.contains(lastTrigger)) lastTrigger.focus();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return el;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openModal(url, kind, trigger) {
|
|
67
|
+
if (typeof HTMLDialogElement === 'undefined') { window.location.href = url; return; }
|
|
68
|
+
lastTrigger = trigger || null;
|
|
69
|
+
fetch(url, { headers: { [FRAGMENT_HEADER]: '1', Accept: 'text/html' }, credentials: 'same-origin' })
|
|
70
|
+
.then(function (res) { if (!res.ok) throw new Error(res.status); return res.text(); })
|
|
71
|
+
.then(function (html) {
|
|
72
|
+
$$('dialog[open]').forEach(function (d) { if (d.id === 'pf-dialog' || d.id === 'pf-drawer') d.close(); });
|
|
73
|
+
const el = modalElement(kind);
|
|
74
|
+
setContent(el, html);
|
|
75
|
+
const role = $('[data-pf-role]', el);
|
|
76
|
+
el.setAttribute('role', role ? role.getAttribute('data-pf-role') : 'dialog');
|
|
77
|
+
if (kind !== 'drawer') el.classList.toggle('pf-dialog--wide', !!$('[data-pf-size="wide"]', el));
|
|
78
|
+
const title = $('[data-pf-title]', el);
|
|
79
|
+
if (title) { if (!title.id) title.id = el.id + '-title'; el.setAttribute('aria-labelledby', title.id); }
|
|
80
|
+
el.showModal();
|
|
81
|
+
enhance(el);
|
|
82
|
+
// Not the form's out-of-sight default button, nor a hidden one: focus() on
|
|
83
|
+
// those lands nowhere visible and the first real control never gets it.
|
|
84
|
+
const focus = $('[autofocus]', el) || $('input:not([type=hidden]), select, textarea, button:not([hidden]):not([tabindex="-1"])', el);
|
|
85
|
+
if (focus) focus.focus();
|
|
86
|
+
})
|
|
87
|
+
.catch(function () { window.location.href = url; });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
on('click', 'a[data-pf-modal]', function (e, a) {
|
|
91
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
openModal(a.href, a.getAttribute('data-pf-modal'), a);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// GET forms marked data-pf-modal (e.g. "Revoke from selected") open their
|
|
97
|
+
// confirmation page in a dialog.
|
|
98
|
+
document.addEventListener('submit', function (e) {
|
|
99
|
+
const form = e.target;
|
|
100
|
+
if (!form.matches || !form.matches('form[data-pf-modal]') || form.closest('dialog')) return;
|
|
101
|
+
if ((form.getAttribute('method') || 'get').toLowerCase() !== 'get') return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
const data = new FormData(form);
|
|
104
|
+
const action = form.getAttribute('action');
|
|
105
|
+
openModal(action + (action.indexOf('?') < 0 ? '?' : '&') + new URLSearchParams(data).toString(),
|
|
106
|
+
form.getAttribute('data-pf-modal'), e.submitter || null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Hands a submission back to the browser the way the user made it: pressing
|
|
110
|
+
// the same button again keeps its name/value and formaction/formmethod, all
|
|
111
|
+
// of which form.submit() drops. The flag lets it past the handlers below.
|
|
112
|
+
function submitNatively(form, submitter) {
|
|
113
|
+
// form.submit() fires no submit event, so the leave-page guard is dropped here.
|
|
114
|
+
if (!submitter) { form._pfDirty = false; form.submit(); return; }
|
|
115
|
+
form._pfNative = true;
|
|
116
|
+
submitter.click();
|
|
117
|
+
form._pfNative = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Batched-edit forms (data-pf-review) show their "Review and save" page in a
|
|
121
|
+
// dialog. A redirect (nothing changed) is followed as a normal navigation.
|
|
122
|
+
document.addEventListener('submit', function (e) {
|
|
123
|
+
const form = e.target;
|
|
124
|
+
if (!form.matches || !form.matches('form[data-pf-review]') || typeof HTMLDialogElement === 'undefined' || form._pfNative) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
// The page stays, so the edits are still unsaved: re-arm the leave-page guard
|
|
127
|
+
// that the form's own submit listener has just dropped. The dialog's form
|
|
128
|
+
// drops it again when a save really happens.
|
|
129
|
+
updateSavebar(form);
|
|
130
|
+
// FormData(form) leaves out the pressed button, and the bulk "Set to"
|
|
131
|
+
// buttons carry the chosen level in their name/value.
|
|
132
|
+
const submitter = e.submitter;
|
|
133
|
+
const body = new FormData(form);
|
|
134
|
+
if (submitter && submitter.name) body.append(submitter.name, submitter.value);
|
|
135
|
+
fetch(form.action, { method: 'POST', body: body, headers: { [FRAGMENT_HEADER]: '1' }, credentials: 'same-origin' })
|
|
136
|
+
.then(function (res) {
|
|
137
|
+
if (res.redirected) { form._pfDirty = false; window.location.href = res.url; return null; }
|
|
138
|
+
return res.text();
|
|
139
|
+
})
|
|
140
|
+
.then(function (html) {
|
|
141
|
+
if (html === null) return;
|
|
142
|
+
const el = modalElement('dialog');
|
|
143
|
+
setContent(el, html);
|
|
144
|
+
el.classList.toggle('pf-dialog--wide', !!$('[data-pf-size="wide"]', el));
|
|
145
|
+
el.setAttribute('role', 'dialog');
|
|
146
|
+
el.showModal();
|
|
147
|
+
enhance(el);
|
|
148
|
+
const save = $('button[type=submit]', el);
|
|
149
|
+
if (save) save.focus();
|
|
150
|
+
$$('form', el).forEach(function (f) { f.addEventListener('submit', function () { form._pfDirty = false; }); });
|
|
151
|
+
})
|
|
152
|
+
.catch(function () { submitNatively(form, submitter); });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
on('click', '[data-pf-close]', function (e, btn) {
|
|
156
|
+
const dialog = btn.closest('dialog');
|
|
157
|
+
if (dialog) { e.preventDefault(); dialog.close(); }
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// GET submissions inside a dialog (person search, filters, formmethod=get
|
|
161
|
+
// buttons) refresh the dialog in place instead of leaving the page.
|
|
162
|
+
document.addEventListener('submit', function (e) {
|
|
163
|
+
const form = e.target;
|
|
164
|
+
const dialog = form.closest && form.closest('dialog.pf-dialog, dialog.pf-drawer');
|
|
165
|
+
if (!dialog || form._pfNative) return;
|
|
166
|
+
const submitter = e.submitter;
|
|
167
|
+
const method = ((submitter && submitter.getAttribute('formmethod')) || form.getAttribute('method') || 'get').toLowerCase();
|
|
168
|
+
if (method !== 'get' && submitter && submitter.hasAttribute('data-pf-stay')) {
|
|
169
|
+
// POST that re-renders the same drawer (e.g. "Check names").
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
const body = new FormData(form);
|
|
172
|
+
if (submitter.name) body.append(submitter.name, submitter.value);
|
|
173
|
+
fetch(submitter.getAttribute('formaction') || form.action, { method: 'POST', body: body, headers: { [FRAGMENT_HEADER]: '1' }, credentials: 'same-origin' })
|
|
174
|
+
.then(function (r) { return r.text(); })
|
|
175
|
+
.then(function (html) { setContent(dialog, html); enhance(dialog); })
|
|
176
|
+
.catch(function () { submitNatively(form, submitter); });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (method !== 'get') return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
const action = (submitter && submitter.getAttribute('formaction')) || form.getAttribute('action');
|
|
182
|
+
const data = new FormData(form);
|
|
183
|
+
data.delete('authenticity_token'); data.delete('_method');
|
|
184
|
+
if (submitter && submitter.name) data.append(submitter.name, submitter.value);
|
|
185
|
+
const url = action + (action.indexOf('?') < 0 ? '?' : '&') + new URLSearchParams(data).toString();
|
|
186
|
+
openModal(url, dialog.id === 'pf-drawer' ? 'drawer' : 'dialog', lastTrigger);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/* ------------------------------------------------------- close menus */
|
|
190
|
+
document.addEventListener('click', function (e) {
|
|
191
|
+
$$('details.pf-menu[open]').forEach(function (menu) { if (!menu.contains(e.target)) menu.removeAttribute('open'); });
|
|
192
|
+
});
|
|
193
|
+
document.addEventListener('keydown', function (e) {
|
|
194
|
+
if (e.key !== 'Escape') return;
|
|
195
|
+
$$('details.pf-menu[open]').forEach(function (menu) {
|
|
196
|
+
menu.removeAttribute('open');
|
|
197
|
+
const s = $('summary', menu); if (s) s.focus();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------- toasts */
|
|
202
|
+
function armToasts(ctx) {
|
|
203
|
+
$$('.pf-toast', ctx).forEach(function (t) {
|
|
204
|
+
if (t.getAttribute('data-pf-armed')) return;
|
|
205
|
+
t.setAttribute('data-pf-armed', '1');
|
|
206
|
+
setTimeout(function () { if (t.parentNode && !t.matches(':hover, :focus-within')) t.remove(); }, 6000);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
on('click', '[data-pf-dismiss]', function (e, btn) {
|
|
210
|
+
const t = btn.closest('.pf-toast, [data-pf-dismissable]');
|
|
211
|
+
if (t) { e.preventDefault(); t.remove(); }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/* --------------------------------------------------------- auto submit */
|
|
215
|
+
on('change', '[data-pf-autosubmit]', function (e, el) {
|
|
216
|
+
const form = el.form || el.closest('form');
|
|
217
|
+
if (form && form.requestSubmit) form.requestSubmit(); else if (form) form.submit();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/* --------------------------------------------------------- live search */
|
|
221
|
+
function liveSearch(input) {
|
|
222
|
+
const form = input.form;
|
|
223
|
+
const target = document.getElementById(input.getAttribute('data-pf-live'));
|
|
224
|
+
if (!form || !target) return;
|
|
225
|
+
const base = input.getAttribute('data-pf-live-url') || form.getAttribute('action') || window.location.pathname;
|
|
226
|
+
let url = base;
|
|
227
|
+
const data = new FormData(form); data.delete('authenticity_token'); data.delete('_method');
|
|
228
|
+
const query = new URLSearchParams(data).toString();
|
|
229
|
+
url += (url.indexOf('?') < 0 ? '?' : '&') + query;
|
|
230
|
+
fetch(url, { headers: { [FRAGMENT_HEADER]: '1' }, credentials: 'same-origin' })
|
|
231
|
+
.then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); })
|
|
232
|
+
.then(function (html) {
|
|
233
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
234
|
+
const fresh = doc.getElementById(target.id);
|
|
235
|
+
if (fresh) { target.replaceChildren(...fresh.childNodes); enhance(target); }
|
|
236
|
+
if (!input.closest('dialog') && !input.hasAttribute('data-pf-live-url') && window.history && history.replaceState) {
|
|
237
|
+
history.replaceState(null, '', base + '?' + query);
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
.catch(function () {});
|
|
241
|
+
}
|
|
242
|
+
const liveHandler = debounce(liveSearch, 200);
|
|
243
|
+
on('input', 'input[data-pf-live]', function (e, input) { liveHandler(input); });
|
|
244
|
+
|
|
245
|
+
/* --------------------------------------------------------------- copy */
|
|
246
|
+
on('click', '[data-pf-copy]', function (e, btn) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
const text = btn.getAttribute('data-pf-copy');
|
|
249
|
+
const done = function () {
|
|
250
|
+
const label = btn.getAttribute('data-pf-copied') || 'Copied';
|
|
251
|
+
const prev = btn.textContent;
|
|
252
|
+
btn.textContent = label;
|
|
253
|
+
setTimeout(function () { btn.textContent = prev; }, 1600);
|
|
254
|
+
};
|
|
255
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () {});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/* ------------------------------------------------- CRUD dependencies */
|
|
259
|
+
// Within a [data-pf-crud] row: inputs with data-pf-requires="read_access edit_access"
|
|
260
|
+
// are disabled (and shown as "Needs Read") while any required flag is off.
|
|
261
|
+
function syncCrud(row) {
|
|
262
|
+
const state = {};
|
|
263
|
+
$$('input[type=checkbox][data-pf-flag]', row).forEach(function (i) { state[i.getAttribute('data-pf-flag')] = i.checked; });
|
|
264
|
+
$$('input[type=checkbox][data-pf-requires]', row).forEach(function (input) {
|
|
265
|
+
const needs = input.getAttribute('data-pf-requires').split(' ');
|
|
266
|
+
const ok = needs.every(function (f) { return state[f] !== false; });
|
|
267
|
+
const cell = input.closest('.pf-cell, .pf-scope-option');
|
|
268
|
+
if (!ok && input.checked) { input.checked = false; state[input.getAttribute('data-pf-flag')] = false; }
|
|
269
|
+
input.disabled = !ok || input.hasAttribute('data-pf-locked');
|
|
270
|
+
if (cell) cell.setAttribute('data-pf-needs', ok ? 'false' : 'true');
|
|
271
|
+
});
|
|
272
|
+
const editOn = state.edit_access !== false && state.read_access !== false;
|
|
273
|
+
const scope = row.getAttribute('data-pf-crud');
|
|
274
|
+
if (scope) {
|
|
275
|
+
$$('[data-pf-fields-of="' + scope + '"] input[type=radio][value=edit]').forEach(function (r) {
|
|
276
|
+
if (r.hasAttribute('data-pf-locked')) return;
|
|
277
|
+
r.disabled = !editOn;
|
|
278
|
+
if (!editOn && r.checked) {
|
|
279
|
+
const read = r.closest('[role=radiogroup]').querySelector('input[value=read]');
|
|
280
|
+
if (read) { read.checked = true; read.dispatchEvent(new Event('change', { bubbles: true })); }
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
on('change', '[data-pf-crud] input[type=checkbox]', function (e, input) {
|
|
286
|
+
// Run twice: requirement chains (Modify All needs View All needs Read).
|
|
287
|
+
const row = input.closest('[data-pf-crud]'); syncCrud(row); syncCrud(row);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------- unsaved changes */
|
|
291
|
+
function snapshot(form) {
|
|
292
|
+
const out = {};
|
|
293
|
+
Array.prototype.slice.call(form.elements).forEach(function (el) {
|
|
294
|
+
if (!el.name || el.type === 'hidden' || el.type === 'submit' || el.hasAttribute('data-pf-untracked')) return;
|
|
295
|
+
if (el.type === 'checkbox' || el.type === 'radio') out[el.name + '|' + el.value] = el.checked;
|
|
296
|
+
else out[el.name] = el.value;
|
|
297
|
+
});
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function changedNames(form) {
|
|
301
|
+
const now = snapshot(form), base = form._pfBase || {}, names = {};
|
|
302
|
+
Object.keys(now).forEach(function (k) { if (now[k] !== base[k]) names[k.split('|')[0]] = true; });
|
|
303
|
+
return Object.keys(names);
|
|
304
|
+
}
|
|
305
|
+
function updateSavebar(form) {
|
|
306
|
+
const bar = document.getElementById(form.getAttribute('data-pf-track'));
|
|
307
|
+
if (!bar) return;
|
|
308
|
+
const names = changedNames(form);
|
|
309
|
+
// Count logical changes: one per resource flag / field, not per radio.
|
|
310
|
+
const count = names.length;
|
|
311
|
+
bar.setAttribute('data-pf-dirty', count > 0 ? 'true' : 'false');
|
|
312
|
+
const label = $('[data-pf-dirty-count]', bar);
|
|
313
|
+
if (label) label.textContent = fmt(count === 1 ? label.getAttribute('data-pf-one') : label.getAttribute('data-pf-other'), count);
|
|
314
|
+
form._pfDirty = count > 0;
|
|
315
|
+
}
|
|
316
|
+
function trackForm(form) {
|
|
317
|
+
if (form._pfTracked) return;
|
|
318
|
+
form._pfTracked = true;
|
|
319
|
+
form._pfBase = snapshot(form);
|
|
320
|
+
// Inputs may live outside the <form> (form="id"), so listen on the document.
|
|
321
|
+
const refresh = function (e) { if (e.target.form === form) updateSavebar(form); };
|
|
322
|
+
document.addEventListener('change', refresh);
|
|
323
|
+
document.addEventListener('input', debounce(refresh, 150));
|
|
324
|
+
form.addEventListener('submit', function () { form._pfDirty = false; });
|
|
325
|
+
// A reset also unticks the bulk checkboxes (form="…"), so the bulk bar follows.
|
|
326
|
+
form.addEventListener('reset', function () {
|
|
327
|
+
setTimeout(function () { $$('[data-pf-crud]').forEach(syncCrud); $$('[data-pf-bulk]').forEach(syncBulk); updateSavebar(form); }, 0);
|
|
328
|
+
});
|
|
329
|
+
updateSavebar(form);
|
|
330
|
+
}
|
|
331
|
+
window.addEventListener('beforeunload', function (e) {
|
|
332
|
+
const dirty = $$('form[data-pf-track]').some(function (f) { return f._pfDirty; });
|
|
333
|
+
if (dirty) { e.preventDefault(); e.returnValue = ''; }
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/* ------------------------------------------------- bulk selection */
|
|
337
|
+
// The rows a bulk action can reach: shown (not filtered out) and not locked.
|
|
338
|
+
function selectable(scope) {
|
|
339
|
+
return $$('input[type=checkbox][data-pf-select]', scope).filter(function (b) { return !b.disabled && !b.closest('[hidden]'); });
|
|
340
|
+
}
|
|
341
|
+
function syncBulk(scope) {
|
|
342
|
+
const boxes = selectable(scope);
|
|
343
|
+
const picked = boxes.filter(function (b) { return b.checked; }).length;
|
|
344
|
+
$$('[data-pf-bulk-bar]', scope).forEach(function (bar) {
|
|
345
|
+
bar.hidden = picked === 0;
|
|
346
|
+
const c = $('[data-pf-selected-count]', bar);
|
|
347
|
+
if (c) c.textContent = fmt(picked === 1 ? c.getAttribute('data-pf-one') : c.getAttribute('data-pf-other'), picked);
|
|
348
|
+
});
|
|
349
|
+
const all = $('input[type=checkbox][data-pf-select-all]', scope);
|
|
350
|
+
if (all) { all.checked = picked > 0 && picked === boxes.length; all.indeterminate = picked > 0 && picked < boxes.length; }
|
|
351
|
+
}
|
|
352
|
+
on('change', 'input[data-pf-select-all]', function (e, all) {
|
|
353
|
+
const scope = all.closest('[data-pf-bulk]');
|
|
354
|
+
selectable(scope).forEach(function (b) { b.checked = all.checked; });
|
|
355
|
+
syncBulk(scope);
|
|
356
|
+
});
|
|
357
|
+
on('change', 'input[data-pf-select]', function (e, box) { syncBulk(box.closest('[data-pf-bulk]')); });
|
|
358
|
+
|
|
359
|
+
/* ----------------------------------------------------------- why panel */
|
|
360
|
+
on('click', 'a[data-pf-why]', function (e, a) {
|
|
361
|
+
const key = a.getAttribute('data-pf-why');
|
|
362
|
+
const item = document.getElementById('pf-why-' + key);
|
|
363
|
+
if (!item) return;
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
$$('.pf-why-item').forEach(function (i) { i.removeAttribute('data-pf-active'); });
|
|
366
|
+
$$('[data-pf-why][aria-current]').forEach(function (i) { i.removeAttribute('aria-current'); });
|
|
367
|
+
const empty = $('[data-pf-why-empty]'); if (empty) empty.hidden = true;
|
|
368
|
+
item.setAttribute('data-pf-active', 'true');
|
|
369
|
+
a.setAttribute('aria-current', 'true');
|
|
370
|
+
if (window.history && history.replaceState) {
|
|
371
|
+
const url = new URL(window.location.href); url.searchParams.set('why', key); history.replaceState(null, '', url);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
/* ------------------------------------------------- in-place toggles */
|
|
376
|
+
// <a data-pf-toggle="id" href="?open[]=…"> expands a region without a reload.
|
|
377
|
+
on('click', 'a[data-pf-toggle]', function (e, a) {
|
|
378
|
+
const region = document.getElementById(a.getAttribute('data-pf-toggle'));
|
|
379
|
+
if (!region) return;
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
region.hidden = !region.hidden;
|
|
382
|
+
a.setAttribute('aria-expanded', region.hidden ? 'false' : 'true');
|
|
383
|
+
const icon = $('.pf-disclosure', a);
|
|
384
|
+
if (icon) icon.classList.toggle('pf-disclosure--open', !region.hidden);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/* ------------------------------------------------------ mask preview */
|
|
388
|
+
on('change', 'select[data-pf-mask-select]', function (e, select) {
|
|
389
|
+
const scope = select.closest('[data-pf-mask-scope]') || document;
|
|
390
|
+
$$('[data-pf-mask-preview]', scope).forEach(function (p) { p.hidden = p.getAttribute('data-pf-mask-preview') !== select.value; });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
/* ------------------------------------------- level-dependent panels */
|
|
394
|
+
// Elements with data-pf-show-when="name=value1,value2" toggle with radio groups.
|
|
395
|
+
function syncShowWhen(ctx) {
|
|
396
|
+
$$('[data-pf-show-when]', ctx).forEach(function (el) {
|
|
397
|
+
const spec = el.getAttribute('data-pf-show-when').split('=');
|
|
398
|
+
const form = el.closest('form') || document;
|
|
399
|
+
const checked = form.querySelector('[name="' + spec[0] + '"]:checked') || form.querySelector('select[name="' + spec[0] + '"]');
|
|
400
|
+
const value = checked ? checked.value : '';
|
|
401
|
+
el.hidden = spec[1].split(',').indexOf(value) < 0;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
document.addEventListener('change', function (e) { if (e.target.form || e.target.closest('form')) syncShowWhen(e.target.closest('form')); });
|
|
405
|
+
|
|
406
|
+
/* -------------------------------------------- rule builder refresh */
|
|
407
|
+
// Selects that change the shape of the form (relationship path, field)
|
|
408
|
+
// re-render the builder server-side via the hidden refresh button.
|
|
409
|
+
on('change', 'select[data-pf-refresh]', function (e, select) {
|
|
410
|
+
const btn = select.form && select.form.querySelector('[data-pf-refresh-button]');
|
|
411
|
+
if (!btn) return;
|
|
412
|
+
const dialog = select.closest('dialog');
|
|
413
|
+
if (dialog) {
|
|
414
|
+
const fd = new FormData(select.form); fd.append(btn.name, btn.value);
|
|
415
|
+
const url = btn.getAttribute('formaction') || select.form.action;
|
|
416
|
+
fetch(url, { method: 'POST', body: fd, headers: { [FRAGMENT_HEADER]: '1' }, credentials: 'same-origin' })
|
|
417
|
+
.then(function (r) { return r.text(); })
|
|
418
|
+
.then(function (html) { setContent(dialog, html); enhance(dialog); })
|
|
419
|
+
.catch(function () { btn.click(); });
|
|
420
|
+
} else {
|
|
421
|
+
btn.click();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/* ------------------------------------------- typed confirmation */
|
|
426
|
+
on('input', 'input[data-pf-confirm-match]', function (e, input) {
|
|
427
|
+
const btn = input.form && input.form.querySelector('[data-pf-confirm-target]');
|
|
428
|
+
if (btn) btn.disabled = input.value.trim() !== input.getAttribute('data-pf-confirm-match');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/* ------------------------------------------- client-side row filter */
|
|
432
|
+
// Filters rows in place (keeps unsaved edits); the GET form is the no-JS path.
|
|
433
|
+
// A row that is filtered out leaves the bulk selection, as it does on the
|
|
434
|
+
// no-JS path: bulk buttons only ever act on rows that are shown.
|
|
435
|
+
on('input', 'input[data-pf-row-filter]', function (e, input) {
|
|
436
|
+
const box = document.getElementById(input.getAttribute('data-pf-row-filter'));
|
|
437
|
+
if (!box) return;
|
|
438
|
+
const q = input.value.trim().toLowerCase();
|
|
439
|
+
$$('[data-pf-row-text]', box).forEach(function (row) {
|
|
440
|
+
row.hidden = q !== '' && row.getAttribute('data-pf-row-text').indexOf(q) < 0;
|
|
441
|
+
if (row.hidden) $$('input[data-pf-select]', row).forEach(function (b) { b.checked = false; });
|
|
442
|
+
});
|
|
443
|
+
const scope = box.closest('[data-pf-bulk]');
|
|
444
|
+
if (scope) syncBulk(scope);
|
|
445
|
+
});
|
|
446
|
+
on('submit', 'form', function (e, form) {
|
|
447
|
+
if (form.querySelector('input[data-pf-row-filter]')) e.preventDefault();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/* ------------------------------------------------- settings filter */
|
|
451
|
+
on('input', 'input[data-pf-settings-filter]', function (e, input) {
|
|
452
|
+
const q = input.value.trim().toLowerCase();
|
|
453
|
+
$$('[data-pf-setting-row]').forEach(function (row) {
|
|
454
|
+
row.hidden = q !== '' && row.getAttribute('data-pf-setting-row').indexOf(q) < 0;
|
|
455
|
+
});
|
|
456
|
+
$$('details[data-pf-setting-section]').forEach(function (sec) {
|
|
457
|
+
const visible = $$('[data-pf-setting-row]', sec).some(function (r) { return !r.hidden; });
|
|
458
|
+
sec.hidden = !visible;
|
|
459
|
+
if (q !== '' && visible) sec.open = true;
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/* ------------------------------------------------------- enhance */
|
|
464
|
+
function enhance(ctx) {
|
|
465
|
+
armToasts(ctx);
|
|
466
|
+
$$('form[data-pf-track]', ctx).forEach(trackForm);
|
|
467
|
+
$$('[data-pf-crud]', ctx).forEach(function (row) { syncCrud(row); syncCrud(row); });
|
|
468
|
+
$$('[data-pf-bulk]', ctx).forEach(syncBulk);
|
|
469
|
+
$$('[data-pf-js-hide]', ctx).forEach(function (el) { el.hidden = true; });
|
|
470
|
+
$$('[data-pf-js-show]', ctx).forEach(function (el) { el.hidden = false; });
|
|
471
|
+
$$('input[data-pf-confirm-match]', ctx).forEach(function (input) {
|
|
472
|
+
const btn = input.form && input.form.querySelector('[data-pf-confirm-target]');
|
|
473
|
+
if (btn) btn.disabled = input.value.trim() !== input.getAttribute('data-pf-confirm-match');
|
|
474
|
+
});
|
|
475
|
+
syncShowWhen(ctx);
|
|
476
|
+
const focus = $('[data-pf-focus="true"]', ctx);
|
|
477
|
+
if (focus && ctx === document) focus.scrollIntoView({ block: 'center' });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { enhance(document); });
|
|
481
|
+
else enhance(document);
|
|
482
|
+
})();
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
module PermissionForge
|
|
2
|
+
# @api private
|
|
3
|
+
module ApplicationHelper
|
|
4
|
+
include PermissionForge::UiHelper
|
|
5
|
+
include PermissionForge::BadgesHelper
|
|
6
|
+
include PermissionForge::PeopleHelper
|
|
7
|
+
include PermissionForge::SentencesHelper
|
|
8
|
+
include PermissionForge::AuditSentencesHelper
|
|
9
|
+
include PermissionForge::ChangesHelper
|
|
10
|
+
include PermissionForge::AuditHelper
|
|
11
|
+
include PermissionForge::SettingsHelper
|
|
12
|
+
|
|
13
|
+
# Sidebar link; the current section gets aria-current="page".
|
|
14
|
+
def pf_nav_link(label, path, badge: nil, badge_label: nil, exact: false)
|
|
15
|
+
current = exact ? request.path == path : request.path.start_with?(path)
|
|
16
|
+
body = [content_tag(:span, label, class: 'pf-nav__label')]
|
|
17
|
+
body << content_tag(:span, badge, class: 'pf-nav__badge', 'aria-label': badge_label) if badge.to_i.positive?
|
|
18
|
+
link_to safe_join(body), path, class: 'pf-nav__link', 'aria-current': (current ? 'page' : nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pf_status_badge(active)
|
|
22
|
+
if active
|
|
23
|
+
pf_badge("● #{t('permission_forge.shared.active')}", variant: :grant)
|
|
24
|
+
else
|
|
25
|
+
pf_badge("○ #{t('permission_forge.shared.inactive')}", variant: :off)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Host app name shown in the "Back to …" link under the logo.
|
|
30
|
+
def pf_app_name
|
|
31
|
+
PermissionForge.configuration.admin_app_name.presence || pf_default_app_name
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Where "Back to <app>" leads: config.admin_back_url, else the host app's
|
|
35
|
+
# root route, else "/".
|
|
36
|
+
def pf_back_to_app_url
|
|
37
|
+
target = PermissionForge.configuration.admin_back_url
|
|
38
|
+
return target.call(self).to_s if target.respond_to?(:call)
|
|
39
|
+
return target.to_s if target.present?
|
|
40
|
+
|
|
41
|
+
main_app.respond_to?(:root_path) ? main_app.root_path : '/'
|
|
42
|
+
rescue StandardError
|
|
43
|
+
'/'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pf_back_to_app_label
|
|
47
|
+
name = pf_app_name
|
|
48
|
+
name ? t('permission_forge.shared.sidebar.back_to_app', app: name) : t('permission_forge.shared.sidebar.back_to_app_generic')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def pf_default_app_name
|
|
52
|
+
return nil unless defined?(Rails) && Rails.application
|
|
53
|
+
|
|
54
|
+
Rails.application.class.module_parent_name.to_s.titleize.presence
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pf_time_ago(time)
|
|
58
|
+
return t('permission_forge.shared.time_nil') if time.nil?
|
|
59
|
+
|
|
60
|
+
t('permission_forge.shared.time_ago', time: time_ago_in_words(time))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def pf_available_locales
|
|
64
|
+
allowed = PermissionForge.configuration.available_locales || PermissionForge::BUNDLED_LOCALES
|
|
65
|
+
Array(allowed).map(&:to_sym)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
LOCALE_LABELS = {
|
|
69
|
+
en: 'English',
|
|
70
|
+
sq: 'Shqip',
|
|
71
|
+
mk: 'Makedonski'
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
def pf_locale_label(locale)
|
|
75
|
+
LOCALE_LABELS[locale.to_sym] || locale.to_s.upcase
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds a locale-switch link for the CURRENT path without reflecting
|
|
79
|
+
# arbitrary query parameters. Reflecting all of request.query_parameters
|
|
80
|
+
# through url_for lets an attacker inject reserved keys (host, protocol,
|
|
81
|
+
# script_name) and turn the switcher into an off-site link. We keep only the
|
|
82
|
+
# current path and set the (validated) locale.
|
|
83
|
+
def pf_locale_switch_path(locale)
|
|
84
|
+
loc = locale.to_s
|
|
85
|
+
loc = I18n.default_locale.to_s unless pf_available_locales.map(&:to_s).include?(loc)
|
|
86
|
+
"#{request.path}?locale=#{CGI.escape(loc)}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Display label for a user in the assignment dropdown.
|
|
90
|
+
#
|
|
91
|
+
# Honors `config.user_label_formatter` if configured. Otherwise tries
|
|
92
|
+
# `:name`, `:full_name`, `:display_name`, then `:email`, then composes
|
|
93
|
+
# `first_name`/`last_name`, and finally falls back to `User #<id>`.
|
|
94
|
+
# The id is appended in `#<id>` form to keep labels unique.
|
|
95
|
+
def pf_user_option_label(user)
|
|
96
|
+
formatter = PermissionForge.configuration.user_label_formatter
|
|
97
|
+
base = formatter ? formatter.call(user).to_s.strip : pf_default_user_label(user)
|
|
98
|
+
base = 'User' if base.blank?
|
|
99
|
+
"#{base} ##{user.id}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def pf_default_user_label(user)
|
|
105
|
+
name = pf_first_present(user, :name, :full_name, :display_name) || pf_compose_name(user)
|
|
106
|
+
email = pf_raw_user_attribute(user, :email).to_s.strip.presence
|
|
107
|
+
pf_format_name_and_email(name, email)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def pf_compose_name(user)
|
|
111
|
+
[pf_raw_user_attribute(user, :first_name), pf_raw_user_attribute(user, :last_name)]
|
|
112
|
+
.compact.map { |part| part.to_s.strip }.reject(&:empty?).join(' ').presence
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def pf_format_name_and_email(name, email)
|
|
116
|
+
return "#{name} (#{email})" if name && email
|
|
117
|
+
return name if name
|
|
118
|
+
return email if email
|
|
119
|
+
|
|
120
|
+
t('permission_forge.shared.user')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def pf_first_present(user, *methods)
|
|
124
|
+
methods.each do |m|
|
|
125
|
+
value = pf_raw_user_attribute(user, m).to_s.strip
|
|
126
|
+
return value unless value.empty?
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Reads a user attribute for the admin assignment dropdown, bypassing
|
|
132
|
+
# MaskedAttributes. The dropdown is permission-management infrastructure —
|
|
133
|
+
# admins need to see user labels to assign permissions — so it must not be
|
|
134
|
+
# subject to the same field masking as ordinary reads.
|
|
135
|
+
#
|
|
136
|
+
# Real columns are read via `read_attribute_before_type_cast` FIRST: the
|
|
137
|
+
# public getter on a masked model routes every call through
|
|
138
|
+
# AttributeAccessGuard (a per-attribute permission lookup) and can hand
|
|
139
|
+
# back a masked or hidden sentinel — wrong for these labels and, across a
|
|
140
|
+
# large dropdown, the difference between milliseconds and tens of seconds.
|
|
141
|
+
# Virtual attributes (host-app methods like a composed `display_name`)
|
|
142
|
+
# still resolve via the public method, as does a column whose raw value is
|
|
143
|
+
# blank. Consequence: a host method overriding a *populated* column is not
|
|
144
|
+
# consulted here — `config.user_label_formatter` is the escape hatch for
|
|
145
|
+
# custom label logic.
|
|
146
|
+
def pf_raw_user_attribute(user, attr)
|
|
147
|
+
attr_name = attr.to_s
|
|
148
|
+
if pf_readable_column?(user, attr_name)
|
|
149
|
+
stored = user.read_attribute_before_type_cast(attr_name)
|
|
150
|
+
return stored if stored.to_s.strip.present?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
user.try(attr)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# True when `attr_name` is a real, loaded column on an AR-like user —
|
|
157
|
+
# i.e., safe to read raw. Guards against partial SELECTs from a custom
|
|
158
|
+
# user_list_resolver, where an unloaded column must not be touched.
|
|
159
|
+
def pf_readable_column?(user, attr_name)
|
|
160
|
+
klass = user.class
|
|
161
|
+
klass.respond_to?(:column_names) &&
|
|
162
|
+
klass.column_names.include?(attr_name) &&
|
|
163
|
+
user.respond_to?(:read_attribute_before_type_cast) &&
|
|
164
|
+
(!user.respond_to?(:has_attribute?) || user.has_attribute?(attr_name))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|