open-loam 0.1.0

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 (245) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +71 -0
  3. data/LICENSE +21 -0
  4. data/README.md +177 -0
  5. data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
  6. data/app/jobs/loam/event_delivery_job.rb +26 -0
  7. data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
  8. data/app/jobs/loam/webhook_delivery_job.rb +59 -0
  9. data/app/models/loam/api_token.rb +40 -0
  10. data/app/models/loam/audit_record.rb +18 -0
  11. data/app/models/loam/auth_attempt.rb +13 -0
  12. data/app/models/loam/business_rule.rb +49 -0
  13. data/app/models/loam/business_rule_run.rb +27 -0
  14. data/app/models/loam/comment.rb +28 -0
  15. data/app/models/loam/config.rb +27 -0
  16. data/app/models/loam/custom_field_value.rb +18 -0
  17. data/app/models/loam/dashboard_widget.rb +16 -0
  18. data/app/models/loam/dictionary.rb +20 -0
  19. data/app/models/loam/dictionary_entry.rb +31 -0
  20. data/app/models/loam/event_delivery.rb +38 -0
  21. data/app/models/loam/field_definition.rb +48 -0
  22. data/app/models/loam/inbound_webhook_delivery.rb +24 -0
  23. data/app/models/loam/inbound_webhook_source.rb +55 -0
  24. data/app/models/loam/membership.rb +27 -0
  25. data/app/models/loam/mfa_credential.rb +114 -0
  26. data/app/models/loam/notification.rb +36 -0
  27. data/app/models/loam/pending_action.rb +208 -0
  28. data/app/models/loam/perspective.rb +104 -0
  29. data/app/models/loam/progress_job.rb +103 -0
  30. data/app/models/loam/record_lock.rb +19 -0
  31. data/app/models/loam/scheduled_job.rb +42 -0
  32. data/app/models/loam/search_token.rb +9 -0
  33. data/app/models/loam/sso_identity.rb +13 -0
  34. data/app/models/loam/sso_provider.rb +35 -0
  35. data/app/models/loam/tenant.rb +20 -0
  36. data/app/models/loam/translation.rb +17 -0
  37. data/app/models/loam/webhook_endpoint.rb +26 -0
  38. data/app/views/loam/attachments/_attachments.html.erb +16 -0
  39. data/app/views/loam/comments/_comments.html.erb +24 -0
  40. data/app/views/loam/custom_fields/_fields.html.erb +35 -0
  41. data/lib/generators/loam/entity/entity_generator.rb +160 -0
  42. data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
  43. data/lib/generators/loam/entity/templates/controller.rb +181 -0
  44. data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
  45. data/lib/generators/loam/entity/templates/migration.rb +26 -0
  46. data/lib/generators/loam/entity/templates/model.rb +27 -0
  47. data/lib/generators/loam/entity/templates/policy.rb +10 -0
  48. data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
  49. data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
  50. data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
  51. data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
  52. data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
  53. data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
  54. data/lib/generators/loam/install/install_generator.rb +288 -0
  55. data/lib/generators/loam/install/templates/AGENTS.md +341 -0
  56. data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
  57. data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
  58. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
  59. data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
  60. data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
  61. data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
  62. data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
  63. data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
  64. data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
  65. data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
  66. data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
  67. data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
  68. data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
  69. data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
  70. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
  71. data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
  72. data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
  73. data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
  74. data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
  75. data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
  76. data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
  77. data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
  78. data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
  79. data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
  80. data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
  81. data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
  82. data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
  83. data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
  84. data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
  85. data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
  86. data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
  87. data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
  88. data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
  89. data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
  90. data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
  91. data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
  92. data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
  93. data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
  94. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
  95. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
  96. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
  97. data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
  98. data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
  99. data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
  100. data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
  101. data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
  102. data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
  103. data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
  104. data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
  105. data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
  106. data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
  107. data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
  108. data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
  109. data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
  110. data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
  111. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
  112. data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
  113. data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
  114. data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
  115. data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
  116. data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
  117. data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
  118. data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
  119. data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
  120. data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
  121. data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
  122. data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
  123. data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
  124. data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
  125. data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
  126. data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
  127. data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
  128. data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
  129. data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
  130. data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
  131. data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
  132. data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
  133. data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
  134. data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
  135. data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
  136. data/lib/generators/loam/install/templates/admin.css +29 -0
  137. data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
  138. data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
  139. data/lib/generators/loam/install/templates/import_job.rb +25 -0
  140. data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
  141. data/lib/generators/loam/install/templates/initializer.rb +218 -0
  142. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
  143. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
  144. data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
  145. data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
  146. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
  147. data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
  148. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
  149. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
  150. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
  151. data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
  152. data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
  153. data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
  154. data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
  155. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
  156. data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
  157. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
  158. data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
  159. data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
  160. data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
  161. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
  162. data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
  163. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
  164. data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
  165. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
  166. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
  167. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
  168. data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
  169. data/lib/generators/loam/install/templates/user.rb +21 -0
  170. data/lib/generators/loam/primary_key_options.rb +101 -0
  171. data/lib/loam/attachable.rb +27 -0
  172. data/lib/loam/auditable.rb +67 -0
  173. data/lib/loam/auth_throttle.rb +83 -0
  174. data/lib/loam/base32.rb +23 -0
  175. data/lib/loam/bulk.rb +59 -0
  176. data/lib/loam/business_rules/actions.rb +92 -0
  177. data/lib/loam/business_rules/condition.rb +102 -0
  178. data/lib/loam/business_rules.rb +123 -0
  179. data/lib/loam/commentable.rb +22 -0
  180. data/lib/loam/configs.rb +115 -0
  181. data/lib/loam/cron.rb +98 -0
  182. data/lib/loam/csv.rb +18 -0
  183. data/lib/loam/current.rb +21 -0
  184. data/lib/loam/custom_field_index.rb +262 -0
  185. data/lib/loam/custom_fields.rb +114 -0
  186. data/lib/loam/dashboard.rb +17 -0
  187. data/lib/loam/dictionaries.rb +55 -0
  188. data/lib/loam/durable_events.rb +174 -0
  189. data/lib/loam/encryptable.rb +145 -0
  190. data/lib/loam/encryption/cipher.rb +75 -0
  191. data/lib/loam/encryption/key_provider.rb +43 -0
  192. data/lib/loam/encryption.rb +125 -0
  193. data/lib/loam/engine.rb +57 -0
  194. data/lib/loam/enrichers.rb +105 -0
  195. data/lib/loam/errors.rb +49 -0
  196. data/lib/loam/eval.rb +47 -0
  197. data/lib/loam/event_stream.rb +80 -0
  198. data/lib/loam/eventful.rb +45 -0
  199. data/lib/loam/events.rb +51 -0
  200. data/lib/loam/export.rb +74 -0
  201. data/lib/loam/features.rb +75 -0
  202. data/lib/loam/generated_key.rb +37 -0
  203. data/lib/loam/import.rb +168 -0
  204. data/lib/loam/inbound_webhooks.rb +125 -0
  205. data/lib/loam/lifecycle.rb +180 -0
  206. data/lib/loam/locales/loam.en.yml +80 -0
  207. data/lib/loam/mcp/server.rb +60 -0
  208. data/lib/loam/mcp.rb +304 -0
  209. data/lib/loam/notifications.rb +36 -0
  210. data/lib/loam/open_api.rb +194 -0
  211. data/lib/loam/overrides.rb +110 -0
  212. data/lib/loam/pending_actions.rb +60 -0
  213. data/lib/loam/permissions.rb +79 -0
  214. data/lib/loam/perspectives.rb +62 -0
  215. data/lib/loam/policy.rb +106 -0
  216. data/lib/loam/progress.rb +31 -0
  217. data/lib/loam/record_locks.rb +89 -0
  218. data/lib/loam/scheduler.rb +153 -0
  219. data/lib/loam/search/driver.rb +31 -0
  220. data/lib/loam/search/like_driver.rb +29 -0
  221. data/lib/loam/search/token_driver.rb +88 -0
  222. data/lib/loam/search.rb +27 -0
  223. data/lib/loam/searchable.rb +90 -0
  224. data/lib/loam/soft_deletable.rb +72 -0
  225. data/lib/loam/sso/claims.rb +10 -0
  226. data/lib/loam/sso/fake_provider.rb +51 -0
  227. data/lib/loam/sso/http_client.rb +42 -0
  228. data/lib/loam/sso/oidc_provider.rb +64 -0
  229. data/lib/loam/sso.rb +129 -0
  230. data/lib/loam/telemetry.rb +43 -0
  231. data/lib/loam/tenant_record.rb +36 -0
  232. data/lib/loam/test_helpers.rb +15 -0
  233. data/lib/loam/totp.rb +63 -0
  234. data/lib/loam/translatable.rb +88 -0
  235. data/lib/loam/undo.rb +126 -0
  236. data/lib/loam/version.rb +3 -0
  237. data/lib/loam/webhooks.rb +34 -0
  238. data/lib/loam/widgets.rb +77 -0
  239. data/lib/loam/workflow.rb +256 -0
  240. data/lib/loam.rb +122 -0
  241. data/lib/open-loam.rb +5 -0
  242. data/lib/tasks/loam.rake +171 -0
  243. data/lib/tasks/loam_eval.rake +32 -0
  244. data/lib/tasks/loam_mcp.rake +20 -0
  245. metadata +332 -0
@@ -0,0 +1,98 @@
1
+ module Admin
2
+ # Managing saved views (Loam::Perspective) for an entity. A view is CREATED by
3
+ # "save current view" on the entity index (capturing the live filters/sort); it
4
+ # starts private, owned by you. Here you rename it, widen its audience, make it
5
+ # the default, or delete it.
6
+ #
7
+ # Who may manage a view: its owner always; a manager for role/tenant-visibility
8
+ # ones. A private view of someone else's is invisible here by construction
9
+ # (Loam::Perspectives.visible_to never returns it).
10
+ class PerspectivesController < BaseController
11
+ before_action :set_perspective, only: %i[update destroy set_default]
12
+ before_action :authorize_manage!, only: %i[update destroy set_default]
13
+
14
+ rescue_from ActiveRecord::StaleObjectError do
15
+ redirect_to admin_perspectives_path(entity_type: params[:entity_type]),
16
+ alert: "Someone else changed that view; nothing was saved. Reload and try again."
17
+ end
18
+
19
+ def index
20
+ @entity_type = params[:entity_type].to_s
21
+ @perspectives = Loam::Perspectives.visible_to(@entity_type, user: current_actor)
22
+ end
23
+
24
+ # From the entity index: save the CURRENT filters/sort as a new private view.
25
+ def create
26
+ perspective = Loam::Perspective.create!(
27
+ entity_type: params[:entity_type].to_s,
28
+ name: params[:name].presence || "Saved view",
29
+ visibility: "private",
30
+ owner_id: current_actor.id,
31
+ config: captured_config
32
+ )
33
+ redirect_to index_path_for(perspective), notice: "Saved the current view."
34
+ end
35
+
36
+ def update
37
+ # Widening a private view to role/tenant is a manager action; and changing
38
+ # the audience clears the default flag so it is re-chosen deliberately.
39
+ attributes = perspective_params
40
+ if attributes[:visibility].present? && attributes[:visibility] != @perspective.visibility
41
+ raise Loam::NotAuthorizedError if attributes[:visibility] != "private" && current_role != :manager
42
+
43
+ attributes[:is_default] = false
44
+ end
45
+
46
+ if @perspective.update(attributes)
47
+ redirect_to index_path_for(@perspective), notice: "View updated."
48
+ else
49
+ redirect_to index_path_for(@perspective), alert: @perspective.errors.full_messages.to_sentence
50
+ end
51
+ end
52
+
53
+ def set_default
54
+ @perspective.make_default!
55
+ redirect_to index_path_for(@perspective), notice: "Default view set."
56
+ end
57
+
58
+ def destroy
59
+ perspective = @perspective
60
+ perspective.destroy!
61
+ redirect_to index_path_for(perspective), notice: "View deleted."
62
+ end
63
+
64
+ private
65
+
66
+ def set_perspective
67
+ @perspective = Loam::Perspective.find(params[:id])
68
+ end
69
+
70
+ def authorize_manage!
71
+ raise Loam::NotAuthorizedError unless can_manage?(@perspective)
72
+ end
73
+
74
+ # Owner always; a manager for shared (role/tenant) views. A private view of
75
+ # another user is never even loaded here, so this only guards shared ones.
76
+ def can_manage?(perspective)
77
+ perspective.owner_id == current_actor.id || (perspective.visibility != "private" && current_role == :manager)
78
+ end
79
+
80
+ def perspective_params
81
+ params.require(:perspective).permit(:name, :visibility, :role)
82
+ end
83
+
84
+ # Config is built from the index's live params, never from trusted raw JSON.
85
+ # apply() whitelists columns regardless, so nothing here can smuggle a filter
86
+ # onto a plumbing column.
87
+ def captured_config
88
+ {
89
+ "filters" => { "q" => params[:q].presence }.compact,
90
+ "sort" => { "field" => params[:sort_field].presence, "dir" => params[:sort_dir].presence }.compact
91
+ }.reject { |_, value| value.blank? }
92
+ end
93
+
94
+ def index_path_for(perspective)
95
+ admin_perspectives_path(entity_type: perspective.entity_type)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,38 @@
1
+ <h1>Saved views — <%%= @entity_type %></h1>
2
+
3
+ <p>Named views of the <%%= @entity_type %> list. Save one from the <%%= @entity_type %>
4
+ index; here you rename it, share it (widen its audience), make it the default,
5
+ or delete it.</p>
6
+
7
+ <%% if @perspectives.empty? %>
8
+ <p>No saved views yet.</p>
9
+ <%% end %>
10
+
11
+ <table>
12
+ <thead>
13
+ <tr><th>Name</th><th>Visibility</th><th>Default</th><th>Owner</th><th></th></tr>
14
+ </thead>
15
+ <tbody>
16
+ <%% @perspectives.each do |perspective| %>
17
+ <tr>
18
+ <td><%%= perspective.name %></td>
19
+ <td><%%= perspective.visibility %><%%= " (#{perspective.role})" if perspective.role.present? %></td>
20
+ <td><%%= "default" if perspective.is_default? %></td>
21
+ <td><%%= User.find_by(id: perspective.owner_id)&.name || "—" %></td>
22
+ <td>
23
+ <%%# Only manageable views (yours, or shared ones if you're a manager) get controls. %>
24
+ <%% if perspective.owner_id == current_actor.id || (perspective.visibility != "private" && current_role == :manager) %>
25
+ <%%= form_with url: admin_perspective_path(perspective, entity_type: @entity_type), method: :patch, class: "inline" do %>
26
+ <%%= text_field_tag :"perspective[name]", perspective.name, size: 16 %>
27
+ <%%= select_tag :"perspective[visibility]", options_for_select(Loam::Perspective::VISIBILITIES, perspective.visibility) %>
28
+ <%%= text_field_tag :"perspective[role]", perspective.role, placeholder: "role", size: 8 %>
29
+ <%%= submit_tag "Save" %>
30
+ <%% end %>
31
+ <%%= button_to "Make default", set_default_admin_perspective_path(perspective, entity_type: @entity_type), form: { style: "display:inline" } %>
32
+ <%%= button_to "Delete", admin_perspective_path(perspective, entity_type: @entity_type), method: :delete, form: { style: "display:inline" } %>
33
+ <%% end %>
34
+ </td>
35
+ </tr>
36
+ <%% end %>
37
+ </tbody>
38
+ </table>
@@ -0,0 +1,19 @@
1
+ module Admin
2
+ # Long-running task progress (Loam::ProgressJob) — a tenant-scoped list of
3
+ # recent jobs with a live bar (the loam.progress.updated SSE event updates it).
4
+ # Your app starts jobs with Loam::Progress.start in its own background jobs;
5
+ # a running one can be cancelled cooperatively here.
6
+ class ProgressJobsController < BaseController
7
+ def index
8
+ @jobs = Loam::ProgressJob.recent.limit(20)
9
+ end
10
+
11
+ def cancel
12
+ job = Loam::ProgressJob.find(params[:id])
13
+ # Manager-or-owner: a member must not cancel another user's job.
14
+ raise Loam::NotAuthorizedError unless current_role == :manager || job.actor_id == current_actor&.id
15
+ job.cancel! if job.running?
16
+ redirect_to admin_progress_jobs_path, notice: "Job cancelled."
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ <h1>Tasks</h1>
2
+
3
+ <p>Long-running jobs report progress here live (no reload — pushed over SSE).
4
+ Start one from your own code with <code>Loam::Progress.start(name:, total:)</code>.</p>
5
+
6
+ <table>
7
+ <thead>
8
+ <tr><th>Name</th><th>Status</th><th>Progress</th><th>Message</th><th>ETA</th><th></th></tr>
9
+ </thead>
10
+ <tbody>
11
+ <%% @jobs.each do |job| %>
12
+ <tr>
13
+ <td><%%= job.name %><%% if job.stale? %> <em>(stale)</em><%% end %></td>
14
+ <td><span id="progress-status-<%%= job.id %>"><%%= job.status %></span></td>
15
+ <td>
16
+ <div style="background:#eee; width:160px; height:12px; display:inline-block; vertical-align:middle">
17
+ <div id="progress-fill-<%%= job.id %>" style="background:#2e7d32; height:12px; width:<%%= job.percent %>%"></div>
18
+ </div>
19
+ <span id="progress-percent-<%%= job.id %>"><%%= job.percent %>%</span>
20
+ </td>
21
+ <td><%%= job.message %></td>
22
+ <td><%%= job.eta_seconds ? "#{job.eta_seconds}s" : "—" %></td>
23
+ <td>
24
+ <%% if job.running? %>
25
+ <%%= button_to "Cancel", cancel_admin_progress_job_path(job), form: { style: "display:inline" } %>
26
+ <%% end %>
27
+ </td>
28
+ </tr>
29
+ <%% end %>
30
+ </tbody>
31
+ </table>
@@ -0,0 +1,17 @@
1
+ module Admin
2
+ # Manager take-over of an advisory edit lock (Loam::RecordLocks). Only a
3
+ # manager may force-release someone else's lock; the record it points at is
4
+ # resolved through a whitelist (a Loam::TenantRecord subclass), never a raw
5
+ # constantize of user input.
6
+ class RecordLocksController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def destroy
10
+ klass = params[:lockable_type].to_s.safe_constantize
11
+ raise Loam::NotAuthorizedError unless klass && klass < Loam::TenantRecord
12
+
13
+ Loam::RecordLocks.force_release(klass.find(params[:lockable_id]))
14
+ redirect_back fallback_location: admin_root_path, notice: "Lock released — you can edit now."
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,69 @@
1
+ module Admin
2
+ # Recurring schedules (Loam::ScheduledJob) — manager-only. List/create/edit/
3
+ # delete a tenant's schedules, toggle active, and "run now". The runner
4
+ # (`loam:scheduler:tick`, wired to system cron) is what fires them on schedule.
5
+ class ScheduledJobsController < BaseController
6
+ before_action { require_role!(:manager) }
7
+ before_action :set_job, only: %i[edit update destroy run_now]
8
+
9
+ def index
10
+ @jobs = Loam::ScheduledJob.order(:key)
11
+ end
12
+
13
+ def new
14
+ @job = Loam::ScheduledJob.new(scope: "tenant", active: true, schedule: "0 3 * * *")
15
+ end
16
+
17
+ def create
18
+ # scope is forced to "tenant": a tenant admin must never create a
19
+ # "system" schedule (which would run with no tenant context). System
20
+ # schedules are created only by the app/console.
21
+ @job = Loam::ScheduledJob.new(job_params.merge(scope: "tenant"))
22
+ @job.next_run_at ||= safe_next_run(@job)
23
+ if @job.save
24
+ redirect_to admin_scheduled_jobs_path, notice: "Schedule created."
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit; end
31
+
32
+ def update
33
+ @job.assign_attributes(job_params)
34
+ @job.next_run_at = safe_next_run(@job) if @job.schedule_changed? || @job.timezone_changed?
35
+ if @job.save
36
+ redirect_to admin_scheduled_jobs_path, notice: "Schedule updated."
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ def destroy
43
+ @job.destroy!
44
+ redirect_to admin_scheduled_jobs_path, notice: "Schedule deleted."
45
+ end
46
+
47
+ def run_now
48
+ Loam::Scheduler.run_now(@job)
49
+ redirect_to admin_scheduled_jobs_path, notice: "#{@job.name} enqueued."
50
+ end
51
+
52
+ private
53
+
54
+ def set_job
55
+ @job = Loam::ScheduledJob.find(params[:id])
56
+ end
57
+
58
+ def job_params
59
+ params.require(:scheduled_job).permit(:key, :name, :job_class, :schedule, :timezone, :active)
60
+ end
61
+
62
+ # A malformed cron shouldn't 500 — let validation surface it instead.
63
+ def safe_next_run(job)
64
+ job.compute_next_run
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ <h1>Edit schedule</h1>
2
+
3
+ <%%= render "form" %>
4
+
5
+ <p><%%= link_to "Back", admin_scheduled_jobs_path %></p>
@@ -0,0 +1,16 @@
1
+ <%%# Loam::ScheduledJob is namespaced, so pin the URL + param scope. %>
2
+ <%%= form_with model: @job, scope: :scheduled_job,
3
+ url: (@job.persisted? ? admin_scheduled_job_path(@job) : admin_scheduled_jobs_path) do |f| %>
4
+ <%% if @job.errors.any? %>
5
+ <ul><%% @job.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
6
+ <%% end %>
7
+
8
+ <p><%%= f.label :key %><br><%%= f.text_field :key %></p>
9
+ <p><%%= f.label :name %><br><%%= f.text_field :name %></p>
10
+ <p><%%= f.label :job_class, "Job class (an ActiveJob, e.g. DemoScheduledJob)" %><br><%%= f.text_field :job_class %></p>
11
+ <p><%%= f.label :schedule, "Schedule (cron '0 7 * * *' or 'interval:3600')" %><br><%%= f.text_field :schedule %></p>
12
+ <p><%%= f.label :timezone, "Timezone (IANA, blank = UTC)" %><br><%%= f.text_field :timezone %></p>
13
+ <p><%%= f.label :active %> <%%= f.check_box :active %></p>
14
+
15
+ <%%= f.submit %>
16
+ <%% end %>
@@ -0,0 +1,28 @@
1
+ <h1>Schedules</h1>
2
+
3
+ <p>Recurring jobs for this tenant. The runner (<code>loam:scheduler:tick</code>,
4
+ wired to system cron) fires them on schedule. <%%= link_to "New schedule", new_admin_scheduled_job_path %></p>
5
+
6
+ <table>
7
+ <thead>
8
+ <tr><th>Key</th><th>Job</th><th>Schedule</th><th>Scope</th><th>Next run</th><th>Last run</th><th>Active</th><th></th></tr>
9
+ </thead>
10
+ <tbody>
11
+ <%% @jobs.each do |job| %>
12
+ <tr>
13
+ <td><%%= job.name %> <small>(<code><%%= job.key %></code>)</small></td>
14
+ <td><code><%%= job.job_class %></code></td>
15
+ <td><code><%%= job.schedule %></code><%%= " #{job.timezone}" if job.timezone.present? %></td>
16
+ <td><%%= job.scope %></td>
17
+ <td><%%= job.next_run_at&.strftime("%Y-%m-%d %H:%M UTC") %></td>
18
+ <td><%%= job.last_run_at&.strftime("%Y-%m-%d %H:%M UTC") || "—" %></td>
19
+ <td><%%= job.active? ? "yes" : "no" %></td>
20
+ <td>
21
+ <%%= button_to "Run now", run_now_admin_scheduled_job_path(job), form: { style: "display:inline" } %>
22
+ <%%= link_to "Edit", edit_admin_scheduled_job_path(job) %>
23
+ <%%= button_to "Delete", admin_scheduled_job_path(job), method: :delete, form: { style: "display:inline" } %>
24
+ </td>
25
+ </tr>
26
+ <%% end %>
27
+ </tbody>
28
+ </table>
@@ -0,0 +1,5 @@
1
+ <h1>New schedule</h1>
2
+
3
+ <%%= render "form" %>
4
+
5
+ <p><%%= link_to "Back", admin_scheduled_jobs_path %></p>
@@ -0,0 +1,49 @@
1
+ module Admin
2
+ # One box, every entity. Results are grouped by entity and limited per group,
3
+ # so a broad query stays readable and cheap.
4
+ #
5
+ # Tenant isolation needs no work here: each model's `search` returns its own
6
+ # tenant-scoped relation, so this can only ever find records the current
7
+ # tenant owns.
8
+ class SearchController < BaseController
9
+ PER_ENTITY = 10
10
+
11
+ helper_method :search_result_label
12
+
13
+ def index
14
+ @query = params[:q].to_s.strip
15
+ @results = @query.blank? ? {} : search_all(@query)
16
+ end
17
+
18
+ # The first searchable column that has a value, plus the id — enough to
19
+ # recognise a record without Loam having to know what a "title" is.
20
+ def search_result_label(record)
21
+ value = record.class.loam_searchable_columns.filter_map { |column| record.public_send(column).presence }.first
22
+
23
+ [ value || record.model_name.human, "##{record.id}" ].join(" ")
24
+ end
25
+
26
+ private
27
+
28
+ def search_all(query)
29
+ searchable_models.filter_map do |model|
30
+ records = model.search(query).order(id: :desc).limit(PER_ENTITY).to_a
31
+ [ model, records ] if records.any?
32
+ end.to_h
33
+ end
34
+
35
+ # Every Loam entity that opted into search, in name order.
36
+ #
37
+ # This eager loads, unlike Admin::CommentsController, and for the opposite
38
+ # reason: there is no user-supplied class name to check here, we need the
39
+ # COMPLETE list of models — and in development classes load lazily, so a
40
+ # model nobody has touched yet would silently be missing from results.
41
+ def searchable_models
42
+ Rails.application.eager_load!
43
+
44
+ Loam::TenantRecord.descendants.select do |model|
45
+ model.name.present? && model.respond_to?(:loam_searchable?) && model.loam_searchable?
46
+ end.sort_by(&:name)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ <h1>Search</h1>
2
+
3
+ <%%= form_with url: admin_search_path, method: :get do |f| %>
4
+ <%%= text_field_tag :q, @query, autofocus: true, placeholder: "Search everything in this tenant" %>
5
+ <%%= f.submit "Search" %>
6
+ <%% end %>
7
+
8
+ <%% if @query.present? %>
9
+ <%% if @results.empty? %>
10
+ <p>Nothing matches <strong><%%= @query %></strong> in <%%= current_tenant.name %>.</p>
11
+ <%% end %>
12
+
13
+ <%% @results.each do |model, records| %>
14
+ <h2><%%= model.model_name.human.pluralize %></h2>
15
+ <ul>
16
+ <%% records.each do |record| %>
17
+ <li>
18
+ <%%= link_to search_result_label(record), [ :admin, record ] %>
19
+ </li>
20
+ <%% end %>
21
+ </ul>
22
+ <%% end %>
23
+ <%% end %>
@@ -0,0 +1,248 @@
1
+ module Admin
2
+ # Email + password login for the admin, then (if the user has MFA) a TOTP
3
+ # challenge, then the tenant pick. A Loam user can belong to several tenants,
4
+ # so: authenticate → second factor → choose a tenant where they hold a
5
+ # membership. The second factor runs BEFORE any tenant is chosen, which is why
6
+ # the MFA secret is keyed to the user, not a tenant (Loam::MfaCredential).
7
+ #
8
+ # Not a BaseController subclass: everything there assumes an established
9
+ # Loam::Current, which is exactly what this controller is trying to build.
10
+ class SessionsController < ActionController::Base
11
+ layout "admin"
12
+
13
+ def new
14
+ return redirect_to mfa_challenge_admin_session_path if session[:mfa_pending]
15
+
16
+ @user = authenticated_user
17
+ @tenants = @user ? Loam::Membership.tenants_for(@user) : Loam::Tenant.none
18
+ end
19
+
20
+ def create
21
+ email = params[:email]
22
+
23
+ # Rate-limit BEFORE touching the password (Loam::AuthThrottle). Throttle by
24
+ # the submitted identifier whether or not the account exists, and give the
25
+ # SAME generic locked response either way — so a lockout can't become an
26
+ # account-existence oracle.
27
+ if Loam::AuthThrottle.locked?(email)
28
+ @error = throttle_message(email)
29
+ return render :new, status: :too_many_requests
30
+ end
31
+
32
+ user = User.authenticate_by(email: email, password: params[:password])
33
+
34
+ if user.nil?
35
+ Loam::AuthThrottle.record_failure(email, kind: "password", ip: request.remote_ip)
36
+ # One message for both cases on purpose: saying which half was wrong
37
+ # tells an attacker which emails exist.
38
+ @error = "Wrong email or password."
39
+ return render :new, status: :unauthorized
40
+ end
41
+
42
+ Loam::AuthThrottle.clear(email) # a successful login resets the counter
43
+ reset_session # a fresh session id at login: no fixation
44
+ session[:user_id] = user.id
45
+
46
+ # Password is only the first factor when MFA is active — hold the login in
47
+ # a "pending" state (no tenant, no access) until the code checks out.
48
+ if Loam::MfaCredential.active_for(user)
49
+ session[:mfa_pending] = true
50
+ redirect_to mfa_challenge_admin_session_path
51
+ else
52
+ complete_authentication(user)
53
+ end
54
+ end
55
+
56
+ # The TOTP challenge screen (only reachable mid-login, password already given).
57
+ def mfa_challenge
58
+ @user = mfa_challenge_user or return redirect_to new_admin_session_path
59
+ end
60
+
61
+ # TODO (follow-up): rate-limit / lock out repeated failed codes here (needs
62
+ # throttling infra) — a 6-digit TOTP is brute-forceable without it.
63
+ def mfa_verify
64
+ user = mfa_challenge_user or return redirect_to new_admin_session_path
65
+
66
+ # A 6-digit TOTP (with drift, several codes valid) is the prime brute-force
67
+ # target — lock it out on the user's identifier.
68
+ if Loam::AuthThrottle.locked?(user.email)
69
+ @user = user
70
+ @error = throttle_message(user.email)
71
+ return render :mfa_challenge, status: :too_many_requests
72
+ end
73
+
74
+ credential = Loam::MfaCredential.active_for(user)
75
+
76
+ if credential.verify_totp(params[:code]) || credential.consume_recovery_code(params[:code])
77
+ Loam::AuthThrottle.clear(user.email)
78
+ session.delete(:mfa_pending)
79
+ complete_authentication(user)
80
+ else
81
+ Loam::AuthThrottle.record_failure(user.email, kind: "totp", ip: request.remote_ip)
82
+ # One generic error — never reveal whether a code was close or a
83
+ # recovery code was already spent.
84
+ @user = user
85
+ @error = "That code is not valid."
86
+ render :mfa_challenge, status: :unauthorized
87
+ end
88
+ end
89
+
90
+ # SSO entry: the user types an email, home-realm discovery finds the tenant's
91
+ # IdP by domain, and we redirect to it. No provider for that domain? Fall
92
+ # back to password login. The `state` is our CSRF token for the round-trip.
93
+ def sso_start
94
+ email = params[:email].to_s
95
+ provider = Loam::Sso.provider_for(email: email)
96
+
97
+ if provider.nil?
98
+ return redirect_to new_admin_session_path(email: email),
99
+ alert: "No SSO provider is configured for that email domain — sign in with your password."
100
+ end
101
+
102
+ state = SecureRandom.urlsafe_base64(24)
103
+ reset_session
104
+ session[:sso_state] = state
105
+ session[:sso_provider_id] = provider.id
106
+ session[:sso_tenant_id] = provider.tenant_id
107
+
108
+ url = Loam.as_tenant(provider.tenant) do
109
+ Loam::Sso.build(provider, redirect_uri: sso_callback_admin_session_url)
110
+ .authorization_url(state: state, login_hint: email)
111
+ end
112
+ redirect_to url, allow_other_host: true
113
+ end
114
+
115
+ # The IdP redirects back here with a code + state. GET, so Rails' form CSRF
116
+ # does not apply — the `state` check IS the CSRF defense. Verify it first,
117
+ # then exchange the code and provision inside the provider's tenant.
118
+ def sso_callback
119
+ state = session.delete(:sso_state)
120
+ provider_id = session.delete(:sso_provider_id)
121
+ tenant_id = session.delete(:sso_tenant_id)
122
+
123
+ if state.blank? || !ActiveSupport::SecurityUtils.secure_compare(state, params[:state].to_s)
124
+ return redirect_to new_admin_session_path, alert: "SSO sign-in could not be verified. Please try again."
125
+ end
126
+
127
+ tenant = Loam::Tenant.find_by(id: tenant_id)
128
+ provider = tenant && Loam.as_tenant(tenant) { Loam::SsoProvider.find_by(id: provider_id) }
129
+ if provider.nil? || !provider.active?
130
+ return redirect_to new_admin_session_path, alert: "That SSO provider is no longer available."
131
+ end
132
+
133
+ user = Loam.as_tenant(tenant) do
134
+ claims = Loam::Sso.build(provider, redirect_uri: sso_callback_admin_session_url).exchange(code: params[:code])
135
+ Loam::Sso.provision(provider, claims)
136
+ end
137
+
138
+ reset_session
139
+ session[:user_id] = user.id
140
+ session[:sso_tenant_id] = tenant.id # land in the IdP's tenant after any MFA
141
+
142
+ # SSO establishes primary auth; if the user also runs app-side MFA, still
143
+ # require the second factor (the safe choice — SSO does not waive it).
144
+ if Loam::MfaCredential.active_for(user)
145
+ session[:mfa_pending] = true
146
+ redirect_to mfa_challenge_admin_session_path
147
+ else
148
+ complete_authentication(user)
149
+ end
150
+ rescue Loam::Sso::Error
151
+ # An unverified email, a domain the provider does not own, or any other
152
+ # provisioning refusal: no session, nothing linked, one generic message.
153
+ reset_session
154
+ redirect_to new_admin_session_path,
155
+ alert: "We couldn't sign you in with SSO. Please contact your administrator."
156
+ end
157
+
158
+ # Step two (or three): the tenant picker posts here.
159
+ def select_tenant
160
+ return redirect_to mfa_challenge_admin_session_path if session[:mfa_pending]
161
+
162
+ user = authenticated_user
163
+ return redirect_to new_admin_session_path if user.nil?
164
+
165
+ tenant = Loam::Membership.tenants_for(user).find_by(id: params[:tenant_id])
166
+
167
+ # A tenant the user has no membership in is simply not in that list, so
168
+ # picking one is impossible rather than merely forbidden.
169
+ return redirect_to new_admin_session_path, alert: "You are not a member of that tenant." if tenant.nil?
170
+
171
+ enter(user, tenant)
172
+ end
173
+
174
+ def destroy
175
+ reset_session
176
+ Loam::Current.reset
177
+ redirect_to new_admin_session_path
178
+ end
179
+
180
+ private
181
+
182
+ def authenticated_user
183
+ User.find_by(id: session[:user_id])
184
+ end
185
+
186
+ def throttle_message(identifier)
187
+ minutes = (Loam::AuthThrottle.remaining_lockout(identifier) / 60.0).ceil
188
+ "Too many attempts. Try again in about #{[ minutes, 1 ].max} minute(s)."
189
+ end
190
+
191
+ # The signed-in user mid-MFA, or nil. Both the pending flag AND the user id
192
+ # must be present, so the challenge is never an oracle reachable without a
193
+ # password.
194
+ def mfa_challenge_user
195
+ return nil unless session[:mfa_pending] && session[:user_id]
196
+
197
+ authenticated_user
198
+ end
199
+
200
+ # Both factors are satisfied here, so this counts as a fresh re-auth: stamp
201
+ # the sudo clock, so a step-up-gated action right after login does not
202
+ # immediately re-challenge.
203
+ def complete_authentication(user)
204
+ session[:sudo_at] = Time.now.to_i
205
+
206
+ # SSO knows exactly which tenant to enter (the IdP's), so honor it even for
207
+ # a multi-tenant user rather than showing the picker.
208
+ if (sso_tenant_id = session.delete(:sso_tenant_id))
209
+ tenant = Loam::Membership.tenants_for(user).find_by(id: sso_tenant_id)
210
+ return enter(user, tenant) if tenant
211
+ end
212
+
213
+ tenants = Loam::Membership.tenants_for(user)
214
+
215
+ if tenants.one?
216
+ enter(user, tenants.first)
217
+ elsif tenants.none?
218
+ reset_session
219
+ @error = "This account has no tenant memberships yet."
220
+ render :new, status: :forbidden
221
+ else
222
+ redirect_to new_admin_session_path
223
+ end
224
+ end
225
+
226
+ def enter(user, tenant)
227
+ session[:tenant_id] = tenant.id
228
+
229
+ if mfa_enrollment_required?(user, tenant)
230
+ redirect_to new_admin_mfa_path, alert: "Your role requires two-factor authentication — set it up to continue."
231
+ else
232
+ redirect_to admin_root_path
233
+ end
234
+ end
235
+
236
+ # A tenant may require MFA for certain roles (security.mfa_required_roles,
237
+ # resolved in that tenant). If the user's role is on the list and they have
238
+ # no active credential, they are sent to enrollment before they can work.
239
+ def mfa_enrollment_required?(user, tenant)
240
+ return false if Loam::MfaCredential.active_for(user)
241
+
242
+ Loam.as_tenant(tenant) do
243
+ role = Loam::Membership.find_by(user_id: user.id)&.role
244
+ role.present? && Array(Loam::Configs.get("security.mfa_required_roles", default: [])).include?(role)
245
+ end
246
+ end
247
+ end
248
+ end