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.
Files changed (406) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1138 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +356 -0
  5. data/app/controllers/concerns/permission_forge/acting_admin.rb +17 -0
  6. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +84 -0
  7. data/app/controllers/concerns/permission_forge/api/nested_resource_lookup.rb +18 -0
  8. data/app/controllers/concerns/permission_forge/api/pagination.rb +50 -0
  9. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +65 -0
  10. data/app/controllers/concerns/permission_forge/api/serializer_resolver.rb +30 -0
  11. data/app/controllers/concerns/permission_forge/api_authentication.rb +40 -0
  12. data/app/controllers/concerns/permission_forge/assignment_authorization.rb +97 -0
  13. data/app/controllers/concerns/permission_forge/audit_log_gate.rb +55 -0
  14. data/app/controllers/concerns/permission_forge/basic_authentication.rb +77 -0
  15. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +32 -0
  16. data/app/controllers/concerns/permission_forge/forgery_protection.rb +29 -0
  17. data/app/controllers/concerns/permission_forge/html_format.rb +40 -0
  18. data/app/controllers/concerns/permission_forge/param_shapes.rb +87 -0
  19. data/app/controllers/concerns/permission_forge/tenant_required.rb +42 -0
  20. data/app/controllers/permission_forge/api/audit_events_controller.rb +33 -0
  21. data/app/controllers/permission_forge/api/base_controller.rb +220 -0
  22. data/app/controllers/permission_forge/api/dashboard_controller.rb +30 -0
  23. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +51 -0
  24. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +71 -0
  25. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +37 -0
  26. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +105 -0
  27. data/app/controllers/permission_forge/api/permission_sets_controller.rb +87 -0
  28. data/app/controllers/permission_forge/api/resource_actions_controller.rb +58 -0
  29. data/app/controllers/permission_forge/api/resource_fields_controller.rb +30 -0
  30. data/app/controllers/permission_forge/api/resources_controller.rb +56 -0
  31. data/app/controllers/permission_forge/application_controller.rb +198 -0
  32. data/app/controllers/permission_forge/assets_controller.rb +97 -0
  33. data/app/controllers/permission_forge/assignments_controller.rb +266 -0
  34. data/app/controllers/permission_forge/audit_events_controller.rb +94 -0
  35. data/app/controllers/permission_forge/bulk_assignments_controller.rb +108 -0
  36. data/app/controllers/permission_forge/field_lookups_controller.rb +38 -0
  37. data/app/controllers/permission_forge/home_controller.rb +24 -0
  38. data/app/controllers/permission_forge/maintenance_controller.rb +13 -0
  39. data/app/controllers/permission_forge/people_controller.rb +190 -0
  40. data/app/controllers/permission_forge/permission_set_editor.rb +71 -0
  41. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +57 -0
  42. data/app/controllers/permission_forge/permission_set_groups_controller.rb +111 -0
  43. data/app/controllers/permission_forge/permission_set_rules_controller.rb +97 -0
  44. data/app/controllers/permission_forge/permission_sets_controller.rb +227 -0
  45. data/app/controllers/permission_forge/preferences_controller.rb +26 -0
  46. data/app/controllers/permission_forge/resource_actions_controller.rb +106 -0
  47. data/app/controllers/permission_forge/resource_fields_controller.rb +129 -0
  48. data/app/controllers/permission_forge/resources_controller.rb +189 -0
  49. data/app/controllers/permission_forge/settings_controller.rb +33 -0
  50. data/app/frontend/permission_forge/brand/apple-touch-icon.png +0 -0
  51. data/app/frontend/permission_forge/brand/chevron.svg +1 -0
  52. data/app/frontend/permission_forge/brand/favicon.ico +0 -0
  53. data/app/frontend/permission_forge/brand/favicon.svg +1 -0
  54. data/app/frontend/permission_forge/brand/logo-horizontal-dark.svg +1 -0
  55. data/app/frontend/permission_forge/brand/logo-horizontal-light.svg +1 -0
  56. data/app/frontend/permission_forge/brand/mark-small.svg +1 -0
  57. data/app/frontend/permission_forge/brand/mark.svg +1 -0
  58. data/app/frontend/permission_forge/pf_admin.css +766 -0
  59. data/app/frontend/permission_forge/pf_admin.js +482 -0
  60. data/app/helpers/permission_forge/application_helper.rb +167 -0
  61. data/app/helpers/permission_forge/audit_helper.rb +230 -0
  62. data/app/helpers/permission_forge/audit_sentences_helper.rb +84 -0
  63. data/app/helpers/permission_forge/badges_helper.rb +134 -0
  64. data/app/helpers/permission_forge/changes_helper.rb +117 -0
  65. data/app/helpers/permission_forge/people_helper.rb +118 -0
  66. data/app/helpers/permission_forge/sentences_helper.rb +175 -0
  67. data/app/helpers/permission_forge/settings_helper.rb +152 -0
  68. data/app/helpers/permission_forge/ui_helper.rb +184 -0
  69. data/app/models/permission_forge/audit_event.rb +159 -0
  70. data/app/models/permission_forge/permission_set.rb +100 -0
  71. data/app/models/permission_forge/permission_set_group.rb +83 -0
  72. data/app/models/permission_forge/permission_set_group_user.rb +43 -0
  73. data/app/models/permission_forge/permission_set_rule.rb +99 -0
  74. data/app/models/permission_forge/permission_set_user.rb +158 -0
  75. data/app/models/permission_forge/resource.rb +100 -0
  76. data/app/models/permission_forge/resource_action.rb +58 -0
  77. data/app/models/permission_forge/resource_field.rb +69 -0
  78. data/app/views/layouts/permission_forge/application.html.erb +30 -0
  79. data/app/views/layouts/permission_forge/fragment.html.erb +2 -0
  80. data/app/views/permission_forge/assignments/_person_options.html.erb +26 -0
  81. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +24 -0
  82. data/app/views/permission_forge/assignments/new.html.erb +99 -0
  83. data/app/views/permission_forge/audit_events/index.html.erb +161 -0
  84. data/app/views/permission_forge/audit_events/show.html.erb +146 -0
  85. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +21 -0
  86. data/app/views/permission_forge/bulk_assignments/new.html.erb +69 -0
  87. data/app/views/permission_forge/field_lookups/show.html.erb +32 -0
  88. data/app/views/permission_forge/home/_audit_card.html.erb +19 -0
  89. data/app/views/permission_forge/home/_ending_card.html.erb +32 -0
  90. data/app/views/permission_forge/home/_expired_card.html.erb +34 -0
  91. data/app/views/permission_forge/home/_privacy_card.html.erb +67 -0
  92. data/app/views/permission_forge/home/_rules_card.html.erb +23 -0
  93. data/app/views/permission_forge/home/_stale_card.html.erb +39 -0
  94. data/app/views/permission_forge/home/_tidy_card.html.erb +18 -0
  95. data/app/views/permission_forge/home/index.html.erb +69 -0
  96. data/app/views/permission_forge/people/_access_tab.html.erb +58 -0
  97. data/app/views/permission_forge/people/_audit_tab.html.erb +93 -0
  98. data/app/views/permission_forge/people/_effective_field_source.html.erb +11 -0
  99. data/app/views/permission_forge/people/_effective_tab.html.erb +118 -0
  100. data/app/views/permission_forge/people/_effective_why.html.erb +48 -0
  101. data/app/views/permission_forge/people/_why_item.html.erb +19 -0
  102. data/app/views/permission_forge/people/confirm_remove_all.html.erb +27 -0
  103. data/app/views/permission_forge/people/consent_preview.html.erb +39 -0
  104. data/app/views/permission_forge/people/erasure_preview.html.erb +28 -0
  105. data/app/views/permission_forge/people/index.html.erb +78 -0
  106. data/app/views/permission_forge/people/show.html.erb +54 -0
  107. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +12 -0
  108. data/app/views/permission_forge/permission_set_groups/index.html.erb +55 -0
  109. data/app/views/permission_forge/permission_set_groups/show.html.erb +100 -0
  110. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +145 -0
  111. data/app/views/permission_forge/permission_set_rules/_value_input.html.erb +42 -0
  112. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +13 -0
  113. data/app/views/permission_forge/permission_set_rules/edit.html.erb +3 -0
  114. data/app/views/permission_forge/permission_set_rules/new.html.erb +3 -0
  115. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +74 -0
  116. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +80 -0
  117. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +83 -0
  118. data/app/views/permission_forge/permission_sets/_header.html.erb +36 -0
  119. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +16 -0
  120. data/app/views/permission_forge/permission_sets/_resource_summary.html.erb +11 -0
  121. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +49 -0
  122. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +23 -0
  123. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +15 -0
  124. data/app/views/permission_forge/permission_sets/index.html.erb +83 -0
  125. data/app/views/permission_forge/permission_sets/new.html.erb +63 -0
  126. data/app/views/permission_forge/permission_sets/review.html.erb +26 -0
  127. data/app/views/permission_forge/permission_sets/show.html.erb +50 -0
  128. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +24 -0
  129. data/app/views/permission_forge/resource_actions/_add_select.html.erb +12 -0
  130. data/app/views/permission_forge/resource_actions/_effect.html.erb +25 -0
  131. data/app/views/permission_forge/resource_actions/_endpoints.html.erb +16 -0
  132. data/app/views/permission_forge/resource_actions/_form.html.erb +44 -0
  133. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +11 -0
  134. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +13 -0
  135. data/app/views/permission_forge/resource_actions/new.html.erb +31 -0
  136. data/app/views/permission_forge/resource_actions/show.html.erb +39 -0
  137. data/app/views/permission_forge/resource_fields/_explain.html.erb +24 -0
  138. data/app/views/permission_forge/resource_fields/show.html.erb +186 -0
  139. data/app/views/permission_forge/resources/_actions_tab.html.erb +88 -0
  140. data/app/views/permission_forge/resources/_fields_tab.html.erb +89 -0
  141. data/app/views/permission_forge/resources/_rules_tab.html.erb +55 -0
  142. data/app/views/permission_forge/resources/_sync.html.erb +57 -0
  143. data/app/views/permission_forge/resources/confirm_destroy.html.erb +17 -0
  144. data/app/views/permission_forge/resources/new.html.erb +57 -0
  145. data/app/views/permission_forge/resources/show.html.erb +117 -0
  146. data/app/views/permission_forge/settings/_ladder.html.erb +15 -0
  147. data/app/views/permission_forge/settings/_row.html.erb +38 -0
  148. data/app/views/permission_forge/settings/show.html.erb +67 -0
  149. data/app/views/permission_forge/shared/_across_sets.html.erb +31 -0
  150. data/app/views/permission_forge/shared/_audit_events.html.erb +36 -0
  151. data/app/views/permission_forge/shared/_audit_footer.html.erb +16 -0
  152. data/app/views/permission_forge/shared/_audit_off_note.html.erb +15 -0
  153. data/app/views/permission_forge/shared/_audit_pagination.html.erb +19 -0
  154. data/app/views/permission_forge/shared/_audit_refused_note.html.erb +7 -0
  155. data/app/views/permission_forge/shared/_audit_timeline.html.erb +29 -0
  156. data/app/views/permission_forge/shared/_crud_cell.html.erb +18 -0
  157. data/app/views/permission_forge/shared/_dialog_frame.html.erb +11 -0
  158. data/app/views/permission_forge/shared/_expiry_picker.html.erb +27 -0
  159. data/app/views/permission_forge/shared/_head_extra.html.erb +3 -0
  160. data/app/views/permission_forge/shared/_help_banner.html.erb +10 -0
  161. data/app/views/permission_forge/shared/_level_control.html.erb +18 -0
  162. data/app/views/permission_forge/shared/_mode_banners.html.erb +22 -0
  163. data/app/views/permission_forge/shared/_nav.html.erb +12 -0
  164. data/app/views/permission_forge/shared/_preferences.html.erb +24 -0
  165. data/app/views/permission_forge/shared/_reason_field.html.erb +20 -0
  166. data/app/views/permission_forge/shared/_savebar.html.erb +13 -0
  167. data/app/views/permission_forge/shared/_sheet_frame.html.erb +22 -0
  168. data/app/views/permission_forge/shared/_sidebar.html.erb +18 -0
  169. data/app/views/permission_forge/shared/_tech_details.html.erb +20 -0
  170. data/app/views/permission_forge/shared/_toasts.html.erb +27 -0
  171. data/app/views/permission_forge/shared/_topbar.html.erb +15 -0
  172. data/app/views/permission_forge/shared/_user_select.html.erb +16 -0
  173. data/app/views/permission_forge/shared/audit_refused.html.erb +7 -0
  174. data/config/locales/permission_forge.en.yml +2115 -0
  175. data/config/locales/permission_forge.mk.yml +2166 -0
  176. data/config/locales/permission_forge.sq.yml +2164 -0
  177. data/config/routes.rb +143 -0
  178. data/lib/generators/permission_forge/install/install_generator.rb +105 -0
  179. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +148 -0
  180. data/lib/generators/permission_forge/install/templates/migration.rb.tt +198 -0
  181. data/lib/generators/permission_forge/rls/rls_generator.rb +57 -0
  182. data/lib/generators/permission_forge/rls/templates/enable_permission_forge_rls.rb.tt +15 -0
  183. data/lib/generators/permission_forge/upgrade/templates/add_admin_ui_columns.rb.tt +8 -0
  184. data/lib/generators/permission_forge/upgrade/templates/add_audit_events.rb.tt +98 -0
  185. data/lib/generators/permission_forge/upgrade/templates/add_resource_fields_unique_index.rb.tt +69 -0
  186. data/lib/generators/permission_forge/upgrade/upgrade_generator.rb +66 -0
  187. data/lib/permission_forge/admin/action_across_sets.rb +76 -0
  188. data/lib/permission_forge/admin/action_rows.rb +156 -0
  189. data/lib/permission_forge/admin/attention_items.rb +207 -0
  190. data/lib/permission_forge/admin/audit_event_detail.rb +75 -0
  191. data/lib/permission_forge/admin/audit_log.rb +193 -0
  192. data/lib/permission_forge/admin/audit_log_days.rb +41 -0
  193. data/lib/permission_forge/admin/audit_lookups.rb +105 -0
  194. data/lib/permission_forge/admin/audit_name_history.rb +85 -0
  195. data/lib/permission_forge/admin/audit_trail.rb +169 -0
  196. data/lib/permission_forge/admin/bulk_people_matcher.rb +109 -0
  197. data/lib/permission_forge/admin/change_set.rb +123 -0
  198. data/lib/permission_forge/admin/change_set_parts/actions.rb +27 -0
  199. data/lib/permission_forge/admin/change_set_parts/base.rb +58 -0
  200. data/lib/permission_forge/admin/change_set_parts/context.rb +50 -0
  201. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +67 -0
  202. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +71 -0
  203. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +91 -0
  204. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +63 -0
  205. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +55 -0
  206. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +19 -0
  207. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +45 -0
  208. data/lib/permission_forge/admin/change_set_parts/values.rb +20 -0
  209. data/lib/permission_forge/admin/column_fields.rb +48 -0
  210. data/lib/permission_forge/admin/conditions_form.rb +78 -0
  211. data/lib/permission_forge/admin/controller_catalog.rb +166 -0
  212. data/lib/permission_forge/admin/csv.rb +41 -0
  213. data/lib/permission_forge/admin/editable_lock.rb +157 -0
  214. data/lib/permission_forge/admin/effective_access.rb +158 -0
  215. data/lib/permission_forge/admin/effective_fields.rb +176 -0
  216. data/lib/permission_forge/admin/expiry_presets.rb +51 -0
  217. data/lib/permission_forge/admin/exports/document.rb +89 -0
  218. data/lib/permission_forge/admin/exports/events.rb +51 -0
  219. data/lib/permission_forge/admin/exports/holdings.rb +118 -0
  220. data/lib/permission_forge/admin/exports.rb +37 -0
  221. data/lib/permission_forge/admin/field_across_sets.rb +51 -0
  222. data/lib/permission_forge/admin/field_detail.rb +112 -0
  223. data/lib/permission_forge/admin/field_level.rb +74 -0
  224. data/lib/permission_forge/admin/field_rows.rb +137 -0
  225. data/lib/permission_forge/admin/last_changes.rb +94 -0
  226. data/lib/permission_forge/admin/model_catalog.rb +216 -0
  227. data/lib/permission_forge/admin/people_candidates.rb +108 -0
  228. data/lib/permission_forge/admin/people_directory.rb +158 -0
  229. data/lib/permission_forge/admin/permission_set_factory.rb +48 -0
  230. data/lib/permission_forge/admin/person_label.rb +95 -0
  231. data/lib/permission_forge/admin/record_id.rb +19 -0
  232. data/lib/permission_forge/admin/rule_builder.rb +58 -0
  233. data/lib/permission_forge/admin/rule_form.rb +175 -0
  234. data/lib/permission_forge/admin/rule_form_input.rb +74 -0
  235. data/lib/permission_forge/admin/sensitive_columns.rb +68 -0
  236. data/lib/permission_forge/admin/settings_catalog.rb +261 -0
  237. data/lib/permission_forge/admin/settings_values.rb +130 -0
  238. data/lib/permission_forge/api/serializers/audit_event_serializer.rb +27 -0
  239. data/lib/permission_forge/api/serializers/base_serializer.rb +36 -0
  240. data/lib/permission_forge/api/serializers/permission_set_group_serializer.rb +12 -0
  241. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +11 -0
  242. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +25 -0
  243. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +13 -0
  244. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +11 -0
  245. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +13 -0
  246. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +14 -0
  247. data/lib/permission_forge/api/serializers/resource_serializer.rb +15 -0
  248. data/lib/permission_forge/audit/actor.rb +144 -0
  249. data/lib/permission_forge/audit/actor_resolver.rb +103 -0
  250. data/lib/permission_forge/audit/audited_views.rb +208 -0
  251. data/lib/permission_forge/audit/boot_warnings.rb +58 -0
  252. data/lib/permission_forge/audit/column_values.rb +56 -0
  253. data/lib/permission_forge/audit/context.rb +98 -0
  254. data/lib/permission_forge/audit/detached.rb +62 -0
  255. data/lib/permission_forge/audit/event_catalog.rb +110 -0
  256. data/lib/permission_forge/audit/event_query.rb +192 -0
  257. data/lib/permission_forge/audit/failure.rb +47 -0
  258. data/lib/permission_forge/audit/field_access.rb +186 -0
  259. data/lib/permission_forge/audit/gate.rb +167 -0
  260. data/lib/permission_forge/audit/job_context.rb +32 -0
  261. data/lib/permission_forge/audit/log_access.rb +36 -0
  262. data/lib/permission_forge/audit/recorder.rb +168 -0
  263. data/lib/permission_forge/audit/refusals.rb +61 -0
  264. data/lib/permission_forge/audit/request_context.rb +152 -0
  265. data/lib/permission_forge/audit/runtime.rb +178 -0
  266. data/lib/permission_forge/audit/runtime_emission.rb +135 -0
  267. data/lib/permission_forge/audit/settings_observer.rb +70 -0
  268. data/lib/permission_forge/audit/source.rb +54 -0
  269. data/lib/permission_forge/audit/subscribers/base.rb +69 -0
  270. data/lib/permission_forge/audit/subscribers/legacy_hooks.rb +97 -0
  271. data/lib/permission_forge/audit/subscribers/log.rb +45 -0
  272. data/lib/permission_forge/audit/subscribers.rb +58 -0
  273. data/lib/permission_forge/audit.rb +302 -0
  274. data/lib/permission_forge/boot_checks.rb +184 -0
  275. data/lib/permission_forge/cache/backlog.rb +142 -0
  276. data/lib/permission_forge/cache/base.rb +200 -0
  277. data/lib/permission_forge/cache/bulk_invalidation.rb +131 -0
  278. data/lib/permission_forge/cache/failure.rb +171 -0
  279. data/lib/permission_forge/cache/handled_errors.rb +82 -0
  280. data/lib/permission_forge/cache/holder_invalidation.rb +85 -0
  281. data/lib/permission_forge/cache/invalidation.rb +180 -0
  282. data/lib/permission_forge/cache/memory_cache.rb +77 -0
  283. data/lib/permission_forge/cache/null_cache.rb +40 -0
  284. data/lib/permission_forge/cache/rails_cache.rb +231 -0
  285. data/lib/permission_forge/cache/rebuild.rb +149 -0
  286. data/lib/permission_forge/cache/settlement.rb +63 -0
  287. data/lib/permission_forge/cache/user_keys.rb +206 -0
  288. data/lib/permission_forge/cache/variant_removal.rb +55 -0
  289. data/lib/permission_forge/cache/withheld_notice.rb +60 -0
  290. data/lib/permission_forge/concerns/audited_collection.rb +34 -0
  291. data/lib/permission_forge/concerns/auto_permission_scoping.rb +158 -0
  292. data/lib/permission_forge/concerns/controller_action_guard.rb +106 -0
  293. data/lib/permission_forge/concerns/field_mask_checker.rb +45 -0
  294. data/lib/permission_forge/concerns/has_active_scope.rb +12 -0
  295. data/lib/permission_forge/concerns/has_audit_trail.rb +59 -0
  296. data/lib/permission_forge/concerns/has_permission_set_users.rb +115 -0
  297. data/lib/permission_forge/concerns/has_tenant_scoping.rb +86 -0
  298. data/lib/permission_forge/concerns/has_uuid.rb +24 -0
  299. data/lib/permission_forge/concerns/invalidates_in_batch.rb +207 -0
  300. data/lib/permission_forge/concerns/invalidates_managed_actions.rb +80 -0
  301. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +280 -0
  302. data/lib/permission_forge/concerns/masked_attributes/store_access.rb +100 -0
  303. data/lib/permission_forge/concerns/masked_attributes/validation_reads.rb +153 -0
  304. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +242 -0
  305. data/lib/permission_forge/concerns/masked_attributes.rb +576 -0
  306. data/lib/permission_forge/concerns/permission_cache_accessor.rb +416 -0
  307. data/lib/permission_forge/concerns/permission_scoped_findable.rb +337 -0
  308. data/lib/permission_forge/concerns/permissioned_user.rb +78 -0
  309. data/lib/permission_forge/concerns/permissions_memo.rb +160 -0
  310. data/lib/permission_forge/concerns/permissions_per_context.rb +288 -0
  311. data/lib/permission_forge/concerns/permissions_regenerator.rb +43 -0
  312. data/lib/permission_forge/concerns/records_audit_events.rb +286 -0
  313. data/lib/permission_forge/concerns/resource_permission_checker.rb +116 -0
  314. data/lib/permission_forge/concerns/validates_column_lengths.rb +69 -0
  315. data/lib/permission_forge/concerns/validates_dependency.rb +54 -0
  316. data/lib/permission_forge/conditional_sets.rb +361 -0
  317. data/lib/permission_forge/configuration/admin_ui_settings.rb +108 -0
  318. data/lib/permission_forge/configuration/audit_settings.rb +184 -0
  319. data/lib/permission_forge/configuration/cache_settings.rb +76 -0
  320. data/lib/permission_forge/configuration/controller_action_settings.rb +112 -0
  321. data/lib/permission_forge/configuration/gdpr_settings.rb +70 -0
  322. data/lib/permission_forge/configuration/masked_attributes_settings.rb +83 -0
  323. data/lib/permission_forge/configuration/permits_attributes_settings.rb +26 -0
  324. data/lib/permission_forge/configuration/presets.rb +139 -0
  325. data/lib/permission_forge/configuration/query_scoping_settings.rb +107 -0
  326. data/lib/permission_forge/configuration/strategy_values.rb +57 -0
  327. data/lib/permission_forge/configuration/virtual_attributes_settings.rb +65 -0
  328. data/lib/permission_forge/configuration.rb +624 -0
  329. data/lib/permission_forge/controller_actions/authorizer.rb +171 -0
  330. data/lib/permission_forge/controller_actions/managed_index.rb +213 -0
  331. data/lib/permission_forge/controller_actions/mapping.rb +167 -0
  332. data/lib/permission_forge/controller_actions.rb +122 -0
  333. data/lib/permission_forge/deprecation.rb +86 -0
  334. data/lib/permission_forge/engine.rb +113 -0
  335. data/lib/permission_forge/execution_state.rb +318 -0
  336. data/lib/permission_forge/invalidation_batch.rb +206 -0
  337. data/lib/permission_forge/masked_models.rb +226 -0
  338. data/lib/permission_forge/masking_sync.rb +271 -0
  339. data/lib/permission_forge/model_bindings.rb +90 -0
  340. data/lib/permission_forge/open_transaction.rb +303 -0
  341. data/lib/permission_forge/operators/constants.rb +41 -0
  342. data/lib/permission_forge/operators/registry.rb +300 -0
  343. data/lib/permission_forge/operators/rule_column.rb +120 -0
  344. data/lib/permission_forge/operators/validations.rb +111 -0
  345. data/lib/permission_forge/operators/value_casters/number.rb +68 -0
  346. data/lib/permission_forge/operators/value_casters/temporal.rb +69 -0
  347. data/lib/permission_forge/operators/value_casters.rb +80 -0
  348. data/lib/permission_forge/operators/value_typing.rb +115 -0
  349. data/lib/permission_forge/query_scoping_warnings.rb +68 -0
  350. data/lib/permission_forge/rake_audit.rb +65 -0
  351. data/lib/permission_forge/resource_chain.rb +190 -0
  352. data/lib/permission_forge/rls.rb +209 -0
  353. data/lib/permission_forge/services/attribute_access_guard.rb +138 -0
  354. data/lib/permission_forge/services/audit_event_purger.rb +150 -0
  355. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +108 -0
  356. data/lib/permission_forge/services/concerns/across_tenants.rb +69 -0
  357. data/lib/permission_forge/services/concerns/audited_operation.rb +43 -0
  358. data/lib/permission_forge/services/concerns/clonable.rb +29 -0
  359. data/lib/permission_forge/services/consent_withdrawal_handler.rb +135 -0
  360. data/lib/permission_forge/services/dashboard_stats.rb +43 -0
  361. data/lib/permission_forge/services/data_subject_erasure.rb +306 -0
  362. data/lib/permission_forge/services/expired_assignment_cleaner.rb +152 -0
  363. data/lib/permission_forge/services/extra_field_defaults.rb +71 -0
  364. data/lib/permission_forge/services/field_permission_merger.rb +83 -0
  365. data/lib/permission_forge/services/filters_response_attributes.rb +144 -0
  366. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +63 -0
  367. data/lib/permission_forge/services/gdpr_field_auditor.rb +223 -0
  368. data/lib/permission_forge/services/host_key_guard.rb +110 -0
  369. data/lib/permission_forge/services/mask_value.rb +93 -0
  370. data/lib/permission_forge/services/permission_cache_clearer.rb +174 -0
  371. data/lib/permission_forge/services/permission_hash_builder.rb +305 -0
  372. data/lib/permission_forge/services/permission_rule_applier.rb +192 -0
  373. data/lib/permission_forge/services/permission_set_cloner.rb +89 -0
  374. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +102 -0
  375. data/lib/permission_forge/services/permission_set_group_cloner.rb +52 -0
  376. data/lib/permission_forge/services/permits_attributes/nested_records.rb +106 -0
  377. data/lib/permission_forge/services/permits_attributes.rb +270 -0
  378. data/lib/permission_forge/services/resource_field_generator.rb +93 -0
  379. data/lib/permission_forge/services/resource_with_fields_builder.rb +47 -0
  380. data/lib/permission_forge/services/rls_operator_sql.rb +131 -0
  381. data/lib/permission_forge/services/rls_policy_generator.rb +365 -0
  382. data/lib/permission_forge/services/security_audit/action_check.rb +157 -0
  383. data/lib/permission_forge/services/security_audit/assignment_check.rb +120 -0
  384. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +166 -0
  385. data/lib/permission_forge/services/security_audit/base_check.rb +57 -0
  386. data/lib/permission_forge/services/security_audit/config_check.rb +212 -0
  387. data/lib/permission_forge/services/security_audit/integrity_check.rb +105 -0
  388. data/lib/permission_forge/services/security_audit/personal_data_check.rb +79 -0
  389. data/lib/permission_forge/services/security_audit/privilege_check.rb +67 -0
  390. data/lib/permission_forge/services/security_audit/rule_check.rb +141 -0
  391. data/lib/permission_forge/services/security_audit/text_report.rb +46 -0
  392. data/lib/permission_forge/services/security_audit/user_model_check.rb +67 -0
  393. data/lib/permission_forge/services/security_audit.rb +75 -0
  394. data/lib/permission_forge/services/stale_assignment_auditor.rb +68 -0
  395. data/lib/permission_forge/services/unscoped_query_detector.rb +132 -0
  396. data/lib/permission_forge/version.rb +3 -0
  397. data/lib/permission_forge/virtual_fields/discovery.rb +65 -0
  398. data/lib/permission_forge/virtual_fields.rb +102 -0
  399. data/lib/permission_forge.rb +464 -0
  400. data/lib/tasks/permission_forge.rake +31 -0
  401. data/lib/tasks/permission_forge_audit_events.rake +28 -0
  402. data/lib/tasks/permission_forge_cache.rake +26 -0
  403. data/lib/tasks/permission_forge_expiration.rake +30 -0
  404. data/lib/tasks/permission_forge_gdpr.rake +69 -0
  405. data/lib/tasks/permission_forge_rls.rake +38 -0
  406. 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