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,32 @@
1
+ <h1>Dry run — <%%= @entity_type %></h1>
2
+
3
+ <p>Nothing was committed. This is what an import WOULD do:</p>
4
+ <ul>
5
+ <li>Create: <strong><%%= @result.created %></strong></li>
6
+ <li>Update: <strong><%%= @result.updated %></strong></li>
7
+ <li>Failed: <strong><%%= @result.failed %></strong></li>
8
+ </ul>
9
+
10
+ <%% if @result.errors.any? %>
11
+ <h2>Failed rows</h2>
12
+ <table>
13
+ <thead><tr><th>Row</th><th>Reason</th></tr></thead>
14
+ <tbody>
15
+ <%% @result.errors.each do |error| %>
16
+ <tr><td><%%= error["row"] %></td><td><%%= error["message"] %></td></tr>
17
+ <%% end %>
18
+ </tbody>
19
+ </table>
20
+
21
+ <%%= form_with url: download_errors_admin_imports_path, method: :post do %>
22
+ <%%= hidden_field_tag :entity_type, @entity_type %>
23
+ <%%= hidden_field_tag :csv, @csv %>
24
+ <%% params[:mapping].to_unsafe_h.each do |header, target| %>
25
+ <%%= hidden_field_tag "mapping[#{header}]", target %>
26
+ <%% end %>
27
+ <%%= hidden_field_tag :match_key, params[:match_key] %>
28
+ <%%= submit_tag "Download error file" %>
29
+ <%% end %>
30
+ <%% end %>
31
+
32
+ <p><%%= link_to "Back", new_admin_import_path(entity_type: @entity_type) %></p>
@@ -0,0 +1,61 @@
1
+ module Admin
2
+ # Manage the external systems allowed to POST webhooks into this tenant
3
+ # (Loam::InboundWebhookSource) — manager-only. Create a source (token + secret
4
+ # are generated), copy the receive URL and secret to the sender, rotate either,
5
+ # toggle active, and see the most recent received deliveries.
6
+ class InboundWebhookSourcesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+ before_action :set_source, only: %i[destroy rotate_secret rotate_token toggle]
9
+
10
+ def index
11
+ @sources = Loam::InboundWebhookSource.order(:name)
12
+ @recent = Loam::InboundWebhookDelivery.order(received_at: :desc).limit(20)
13
+ end
14
+
15
+ def new
16
+ @source = Loam::InboundWebhookSource.new(event_name: "inbound.webhook.received")
17
+ end
18
+
19
+ def create
20
+ @source = Loam::InboundWebhookSource.new(source_params)
21
+ if @source.save
22
+ redirect_to admin_inbound_webhook_sources_path, notice: "Source created. Copy the secret now — it signs every call."
23
+ else
24
+ render :new, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ @source.destroy!
30
+ redirect_to admin_inbound_webhook_sources_path, notice: "Source deleted."
31
+ end
32
+
33
+ def rotate_secret
34
+ @source.rotate_secret!
35
+ redirect_to admin_inbound_webhook_sources_path, notice: "Secret rotated — update the sender."
36
+ end
37
+
38
+ def rotate_token
39
+ @source.rotate_token!
40
+ redirect_to admin_inbound_webhook_sources_path, notice: "Token rotated — update the sender's URL."
41
+ end
42
+
43
+ def toggle
44
+ @source.update!(active: !@source.active?)
45
+ redirect_to admin_inbound_webhook_sources_path, notice: "Source #{@source.active? ? 'enabled' : 'disabled'}."
46
+ end
47
+
48
+ private
49
+
50
+ def set_source
51
+ @source = Loam::InboundWebhookSource.find(params[:id])
52
+ end
53
+
54
+ def source_params
55
+ params.require(:inbound_webhook_source).permit(
56
+ :name, :event_name, :signature_header, :delivery_id_header,
57
+ :timestamp_header, :timestamp_tolerance
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ <h1>Inbound webhooks</h1>
2
+
3
+ <p>External systems allowed to POST webhooks into this tenant. The <strong>URL</strong>
4
+ identifies a source; the <strong>secret</strong> (HMAC-SHA256 over the raw body,
5
+ sent in the signature header) authenticates each call. A verified delivery is
6
+ published on the event bus as its <code>event_name</code>.
7
+ <%%= link_to "New source", new_admin_inbound_webhook_source_path %></p>
8
+
9
+ <table>
10
+ <thead>
11
+ <tr><th>Name</th><th>Receive URL</th><th>Event</th><th>Active</th><th></th></tr>
12
+ </thead>
13
+ <tbody>
14
+ <%% @sources.each do |s| %>
15
+ <tr>
16
+ <td><%%= s.name %></td>
17
+ <td><code><%%= "#{request.base_url}/webhooks/#{s.token}" %></code><br>
18
+ <small>secret: <code><%%= s.secret %></code></small></td>
19
+ <td><code><%%= s.event_name %></code></td>
20
+ <td><%%= s.active? ? "yes" : "no" %></td>
21
+ <td>
22
+ <%%= button_to "Rotate token", rotate_token_admin_inbound_webhook_source_path(s), form: { style: "display:inline" } %>
23
+ <%%= button_to "Rotate secret", rotate_secret_admin_inbound_webhook_source_path(s), form: { style: "display:inline" } %>
24
+ <%%= button_to (s.active? ? "Disable" : "Enable"), toggle_admin_inbound_webhook_source_path(s), form: { style: "display:inline" } %>
25
+ <%%= button_to "Delete", admin_inbound_webhook_source_path(s), method: :delete, form: { style: "display:inline" } %>
26
+ </td>
27
+ </tr>
28
+ <%% end %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <h2>Recent deliveries</h2>
33
+ <%% if @recent.any? %>
34
+ <table>
35
+ <thead><tr><th>Source</th><th>Event</th><th>External id</th><th>Received</th></tr></thead>
36
+ <tbody>
37
+ <%% @recent.each do |d| %>
38
+ <tr>
39
+ <td><%%= d.source.name %></td>
40
+ <td><code><%%= d.event_name %></code></td>
41
+ <td><small><%%= truncate(d.external_id, length: 24) %></small></td>
42
+ <td><%%= d.received_at&.strftime("%Y-%m-%d %H:%M UTC") %></td>
43
+ </tr>
44
+ <%% end %>
45
+ </tbody>
46
+ </table>
47
+ <%% else %>
48
+ <p>No deliveries received yet.</p>
49
+ <%% end %>
@@ -0,0 +1,32 @@
1
+ <h1>New inbound webhook source</h1>
2
+ <p><%%= link_to "Back", admin_inbound_webhook_sources_path %></p>
3
+
4
+ <%%= form_with model: @source, url: admin_inbound_webhook_sources_path, method: :post do |f| %>
5
+ <%% if @source.errors.any? %>
6
+ <ul><%% @source.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
7
+ <%% end %>
8
+ <p>
9
+ <%%= f.label :name %><br>
10
+ <%%= f.text_field :name %>
11
+ </p>
12
+ <p>
13
+ <%%= f.label :event_name, "Publish as event (domain.thing.happened)" %><br>
14
+ <%%= f.text_field :event_name %>
15
+ </p>
16
+ <p>
17
+ <%%= f.label :signature_header, "Signature header (default X-Loam-Signature)" %><br>
18
+ <%%= f.text_field :signature_header, placeholder: "X-Loam-Signature" %>
19
+ <small>e.g. X-Hub-Signature-256 for GitHub.</small>
20
+ </p>
21
+ <p>
22
+ <%%= f.label :delivery_id_header, "Delivery-id header (optional, for dedupe)" %><br>
23
+ <%%= f.text_field :delivery_id_header %>
24
+ <small>Blank = dedupe on a hash of the body.</small>
25
+ </p>
26
+ <p>
27
+ <%%= f.label :timestamp_header, "Timestamp header (optional, enables freshness window)" %><br>
28
+ <%%= f.text_field :timestamp_header %>
29
+ <%%= f.number_field :timestamp_tolerance, placeholder: "300 (seconds)" %>
30
+ </p>
31
+ <%%= f.submit "Create source" %>
32
+ <%% end %>
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Admin</title>
5
+ <%%= csrf_meta_tags %>
6
+ <%%= stylesheet_link_tag "admin", media: "all" %>
7
+ </head>
8
+ <body>
9
+ <%% flash.each do |type, message| %>
10
+ <div class="flash flash-<%%= type.to_s == "alert" ? "alert" : "notice" %>"><%%= message %></div>
11
+ <%% end %>
12
+ <%% if defined?(current_tenant) && current_tenant %>
13
+ <div class="context">
14
+ <%%= t("loam.chrome.tenant") %>: <strong><%%= current_tenant.name %></strong> ·
15
+ <%%= t("loam.chrome.user") %>: <strong><%%= current_actor&.name %></strong> ·
16
+ <%%= button_to t("loam.chrome.sign_out"), admin_session_path, method: :delete, form: { style: "display:inline" } %>
17
+ </div>
18
+ <nav>
19
+ <%%= link_to t("loam.nav.dashboard"), admin_root_path %>
20
+ <%%= link_to admin_notifications_path do %><%%= t("loam.nav.notifications") %> (<span id="loam-notification-count"><%%= unread_notification_count %></span>)<%% end %>
21
+ <%%= link_to t("loam.nav.api_tokens"), admin_api_tokens_path %>
22
+ <%%= link_to t("loam.nav.security"), admin_mfa_path %>
23
+ <%%= link_to "#{t('loam.nav.approvals')} (#{pending_approval_count})", admin_pending_actions_path %>
24
+ <%%= link_to t("loam.nav.settings"), admin_configs_path %>
25
+ <%%= link_to t("loam.nav.features"), admin_features_path %>
26
+ <%%= link_to t("loam.nav.rules"), admin_business_rules_path %>
27
+ <%%= link_to t("loam.nav.sso"), admin_sso_providers_path %>
28
+ <%%= link_to t("loam.nav.dictionaries"), admin_dictionaries_path %>
29
+ <%%= link_to t("loam.nav.tasks"), admin_progress_jobs_path %>
30
+ <%%= link_to t("loam.nav.schedules"), admin_scheduled_jobs_path %>
31
+ <%%= link_to t("loam.nav.deliveries"), admin_event_deliveries_path %>
32
+ <%%= link_to t("loam.nav.inbound"), admin_inbound_webhook_sources_path %>
33
+ <%%= link_to t("loam.nav.api_docs"), admin_api_docs_path %>
34
+ <%%= link_to t("loam.nav.overrides"), admin_overrides_path %>
35
+ <%%= form_with url: admin_search_path, method: :get, class: "search" do %>
36
+ <%%= text_field_tag :q, params[:q], placeholder: t("loam.chrome.search") %>
37
+ <%% end %>
38
+ <%%# Locale switcher (Loam::Translatable): overlays content onto the picked language. %>
39
+ <%% if Loam.locales.size > 1 %>
40
+ <span class="locale">
41
+ <%% Loam.locales.each do |loc| %><%%= link_to loc.upcase, "#{request.path}?locale=#{loc}", style: (loc == current_locale ? "font-weight:bold" : "") %> <%% end %>
42
+ </span>
43
+ <%% end %>
44
+ </nav>
45
+
46
+ <%%# Live updates over SSE (Loam::EventStream). Degrades gracefully: if
47
+ EventSource is unavailable or the stream drops, the per-page-load bell
48
+ count above still works. %>
49
+ <script>
50
+ (function () {
51
+ if (typeof EventSource === "undefined") return;
52
+ var source = new EventSource("<%%= admin_events_stream_path %>");
53
+ source.addEventListener("loam.notification.created", function () {
54
+ var badge = document.getElementById("loam-notification-count");
55
+ if (badge) badge.textContent = (parseInt(badge.textContent, 10) || 0) + 1;
56
+ });
57
+ // Live task progress (Loam::ProgressJob): move the matching bar. The
58
+ // frame carries only id/percent/status.
59
+ source.addEventListener("loam.progress.updated", function (e) {
60
+ var d = JSON.parse(e.data);
61
+ var fill = document.getElementById("progress-fill-" + d.id);
62
+ if (fill) fill.style.width = d.percent + "%";
63
+ var pct = document.getElementById("progress-percent-" + d.id);
64
+ if (pct) pct.textContent = d.percent + "%";
65
+ var st = document.getElementById("progress-status-" + d.id);
66
+ if (st) st.textContent = d.status;
67
+ });
68
+ source.onerror = function () {
69
+ if (source.readyState === EventSource.CLOSED) source.close();
70
+ };
71
+ })();
72
+ </script>
73
+ <%% end %>
74
+ <%%= yield %>
75
+ </body>
76
+ </html>
@@ -0,0 +1,12 @@
1
+ <h1>Two-factor authentication is on</h1>
2
+
3
+ <p><strong>Save these recovery codes now.</strong> Each works once if you lose
4
+ your authenticator. This is the only time they are shown.</p>
5
+
6
+ <ul>
7
+ <%% @recovery_codes.each do |code| %>
8
+ <li><code><%%= code %></code></li>
9
+ <%% end %>
10
+ </ul>
11
+
12
+ <p><%%= link_to "Done", admin_mfa_path %></p>
@@ -0,0 +1,61 @@
1
+ module Admin
2
+ # A user's own two-factor setup — self-service, so NOT role-gated (everyone
3
+ # manages their own MFA). Enrollment is two steps: `new` (GET) shows a
4
+ # CANDIDATE secret held in the session, `create` (POST) confirms a live code
5
+ # and only then adopts it. Crucially, GET never mutates the credential, and the
6
+ # old secret stays valid until the new one is proven — so neither a cross-site
7
+ # GET nor an abandoned re-enrollment can downgrade an active credential.
8
+ class MfaController < BaseController
9
+ # Shown in authenticator apps as the account issuer; defaults to the app name.
10
+ ISSUER = Rails.application.class.module_parent_name.freeze
11
+
12
+ # Replacing an ACTIVE credential (or disabling it) is sensitive, so gate those
13
+ # writes behind step-up. Initial enrollment (no active MFA yet) needs no sudo
14
+ # — there is nothing to protect and no second factor to prove.
15
+ before_action :require_sudo!, only: %i[create destroy], if: :active_mfa?
16
+
17
+ def show
18
+ @credential = Loam::MfaCredential.active_for(current_actor)
19
+ end
20
+
21
+ # READ-ONLY: mint a candidate secret into the (encrypted cookie) session and
22
+ # display it. No credential is touched, so this is safe to reach via GET.
23
+ def new
24
+ set_enrollment_secret
25
+ end
26
+
27
+ # Confirm the candidate against a live code, then adopt it and reveal the
28
+ # recovery codes once. The active secret is replaced only on success.
29
+ def create
30
+ secret = session[:mfa_enrollment_secret]
31
+ credential = Loam::MfaCredential.find_or_initialize_by(user_id: current_actor.id)
32
+ @recovery_codes = secret && credential.activate_with!(secret, params[:code])
33
+
34
+ if @recovery_codes
35
+ session.delete(:mfa_enrollment_secret)
36
+ render :activated
37
+ else
38
+ set_enrollment_secret
39
+ @error = "That code did not match. Check your authenticator and try again."
40
+ render :new, status: :unprocessable_entity
41
+ end
42
+ end
43
+
44
+ def destroy
45
+ Loam::MfaCredential.where(user_id: current_actor.id).delete_all
46
+ session.delete(:mfa_enrollment_secret)
47
+ redirect_to admin_mfa_path, notice: "Two-factor authentication disabled."
48
+ end
49
+
50
+ private
51
+
52
+ def set_enrollment_secret
53
+ @secret = (session[:mfa_enrollment_secret] ||= Loam::Totp.generate_secret)
54
+ @provisioning_uri = Loam::Totp.provisioning_uri(@secret, account: current_actor.email, issuer: ISSUER)
55
+ end
56
+
57
+ def active_mfa?
58
+ Loam::MfaCredential.active_for(current_actor).present?
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,23 @@
1
+ <%% if @error %>
2
+ <p style="color:#b00"><%%= @error %></p>
3
+ <%% end %>
4
+
5
+ <h1>Set up two-factor authentication</h1>
6
+
7
+ <p>Add this account to your authenticator app, then confirm a code to activate.
8
+ Scan the QR (a later nicety) or enter the details by hand:</p>
9
+
10
+ <ul>
11
+ <li>Secret: <code><%%= @secret %></code></li>
12
+ <li>otpauth URI: <code><%%= @provisioning_uri %></code></li>
13
+ </ul>
14
+
15
+ <%%= form_with url: admin_mfa_path, method: :post do |f| %>
16
+ <p>
17
+ <%%= label_tag :code, "Code from your app" %><br>
18
+ <%%= text_field_tag :code, nil, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric" %>
19
+ </p>
20
+ <%%= f.submit "Activate" %>
21
+ <%% end %>
22
+
23
+ <p><%%= link_to "Cancel", admin_mfa_path %></p>
@@ -0,0 +1,19 @@
1
+ <h1>Two-factor authentication</h1>
2
+
3
+ <%% if @credential %>
4
+ <p>Status: <strong>enabled</strong> — activated <%%= @credential.activated_at.to_date %>.</p>
5
+ <p>Unused recovery codes: <strong><%%= @credential.unused_recovery_code_count %></strong>.</p>
6
+
7
+ <p>
8
+ <%%= link_to "Re-enroll (new secret + codes)", new_admin_mfa_path %>
9
+ </p>
10
+ <%%# Disabling MFA is sensitive, so it is step-up gated (require_sudo!). %>
11
+ <%%= button_to "Disable two-factor", admin_mfa_path, method: :delete,
12
+ data: { turbo_confirm: "Disable two-factor authentication for your account?" } %>
13
+ <%% else %>
14
+ <p>Status: <strong>not enabled</strong>. Protect your account with an
15
+ authenticator app.</p>
16
+ <p><%%= link_to "Set up two-factor authentication", new_admin_mfa_path %></p>
17
+ <%% end %>
18
+
19
+ <p><%%= link_to "Back to dashboard", admin_root_path %></p>
@@ -0,0 +1,23 @@
1
+ module Admin
2
+ # The bell. Every query here is scoped to the current actor, so there is no
3
+ # "read someone else's notifications" path to authorize and no per-record
4
+ # policy to write: tenancy is structural (Loam::Notification is a
5
+ # Loam::TenantRecord) and BaseController has already established that this
6
+ # actor is signed in to this tenant. Recipient scoping is the whole rule.
7
+ class NotificationsController < BaseController
8
+ def index
9
+ @records = notifications.order(created_at: :desc).limit(100)
10
+ end
11
+
12
+ def mark_read
13
+ notifications.find(params[:id]).mark_read!
14
+ redirect_to admin_notifications_path
15
+ end
16
+
17
+ private
18
+
19
+ def notifications
20
+ Loam::Notification.where(user_id: current_actor.id)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ <h1>Notifications</h1>
2
+
3
+ <p>Messages for <strong><%%= current_actor.name %></strong> in <strong><%%= current_tenant.name %></strong>.</p>
4
+
5
+ <table>
6
+ <thead>
7
+ <tr>
8
+ <th>When</th>
9
+ <th>Title</th>
10
+ <th>About</th>
11
+ <th></th>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <%% @records.each do |record| %>
16
+ <tr>
17
+ <td><%%= record.created_at.to_fs(:short) %></td>
18
+ <td>
19
+ <%%= tag.strong record.title unless record.read? %>
20
+ <%%= record.title if record.read? %>
21
+ <%%= tag.div record.body, style: "color:#555" if record.body.present? %>
22
+ </td>
23
+ <td><%%= "#{record.source_type} ##{record.source_id}" if record.source_type %></td>
24
+ <td>
25
+ <%% unless record.read? %>
26
+ <%%= button_to "Mark read", mark_read_admin_notification_path(record), form: { style: "display:inline" } %>
27
+ <%% end %>
28
+ </td>
29
+ </tr>
30
+ <%% end %>
31
+ </tbody>
32
+ </table>
@@ -0,0 +1,14 @@
1
+ module Admin
2
+ # Read-only view of the app's Loam::Overrides — which registry entries are
3
+ # disabled or replaced, plus any STALE overrides (a key that no longer exists).
4
+ # Manager-only, for legibility: it's config declared in an initializer, shown
5
+ # here so an operator can see what's been customized without reading code.
6
+ class OverridesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def index
10
+ @overrides = Loam::Overrides.all
11
+ @stale = Loam::Overrides.stale
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ <h1>Overrides</h1>
2
+
3
+ <p>Registry entries this app disables or replaces (declared in
4
+ <code>config/initializers/loam.rb</code> via <code>Loam::Overrides</code>) —
5
+ customization without forking. Structural pieces (views, controllers, routes)
6
+ are overridden the Rails way (path shadowing), not here.</p>
7
+
8
+ <%% if @stale.any? %>
9
+ <p style="color:#b00"><strong>Stale overrides</strong> (the key no longer exists — the override does nothing):
10
+ <%%= @stale.map { |reg, key| "#{reg}/#{key}" }.join(", ") %></p>
11
+ <%% end %>
12
+
13
+ <table>
14
+ <thead><tr><th>Registry</th><th>Key</th><th>Effect</th></tr></thead>
15
+ <tbody>
16
+ <%% @overrides.each do |registry, entries| %>
17
+ <%% entries.each do |key, value| %>
18
+ <tr>
19
+ <td><code><%%= registry %></code></td>
20
+ <td><code><%%= key %></code></td>
21
+ <td><%%= value == Loam::Overrides::DISABLED ? "disabled" : "replaced" %></td>
22
+ </tr>
23
+ <%% end %>
24
+ <%% end %>
25
+ <%% if @overrides.values.all?(&:empty?) %>
26
+ <tr><td colspan="3"><em>No overrides declared.</em></td></tr>
27
+ <%% end %>
28
+ </tbody>
29
+ </table>
@@ -0,0 +1,23 @@
1
+ module Admin
2
+ # Pagination with no gem attached — the smallest thing that works:
3
+ #
4
+ # @records, @page, @has_next = paginate(Equipment.order(created_at: :desc))
5
+ #
6
+ # Swap in pagy or kaminari when you need total counts, numbered pages or a
7
+ # window of links; callers only ever use these three values, so the change
8
+ # stays inside this module and the index views.
9
+ module Pagination
10
+ PER_PAGE = 25
11
+
12
+ def paginate(scope, per_page: PER_PAGE)
13
+ page = params[:page].to_i
14
+ page = 1 if page < 1
15
+
16
+ # One row more than a page is fetched and never rendered: it answers
17
+ # "is there a next page?" without a second COUNT over the whole table.
18
+ records = scope.limit(per_page + 1).offset((page - 1) * per_page).to_a
19
+
20
+ [ records.first(per_page), page, records.size > per_page ]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ module Admin
2
+ # The approval queue: staged mutations (Loam::PendingAction) waiting on a
3
+ # human. Manager-only — approving EXECUTES the change. The workflow's role gate
4
+ # is the deeper enforcement (approve!/reject! raise for a non-manager); this
5
+ # screen is a manager screen on top of it.
6
+ class PendingActionsController < BaseController
7
+ before_action { require_role!(:manager) }
8
+ before_action :set_pending_action, only: %i[approve reject]
9
+
10
+ def index
11
+ @pending = Loam::PendingAction.pending.order(created_at: :desc)
12
+ end
13
+
14
+ def approve
15
+ @pending_action.approve!(by: current_actor)
16
+ redirect_to admin_pending_actions_path, notice: approval_notice(@pending_action)
17
+ end
18
+
19
+ def reject
20
+ @pending_action.reject!(by: current_actor, reason: params[:reason])
21
+ redirect_to admin_pending_actions_path, notice: "Rejected: #{@pending_action.summary}"
22
+ end
23
+
24
+ private
25
+
26
+ def set_pending_action
27
+ @pending_action = Loam::PendingAction.find(params[:id])
28
+ end
29
+
30
+ def approval_notice(pending)
31
+ if pending.executed?
32
+ "Approved and applied: #{pending.summary}"
33
+ else
34
+ "Approved, but execution failed: #{pending.error}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ <h1>Approvals</h1>
2
+
3
+ <p>Changes an agent (or any confirm-mode caller) staged for review. Approving
4
+ executes the change; nothing has been applied yet.</p>
5
+
6
+ <%% if @pending.empty? %>
7
+ <p>Nothing waiting for review.</p>
8
+ <%% end %>
9
+
10
+ <%% @pending.each do |pending| %>
11
+ <section style="border:1px solid #ccc; padding:1rem; margin-bottom:1rem">
12
+ <h2><%%= pending.summary %></h2>
13
+ <p>
14
+ <%%= pending.action_type %> on <%%= pending.target_type %><%%= " ##{pending.target_id}" if pending.target_id %> ·
15
+ staged by <%%= User.find_by(id: pending.actor_id)&.name || "—" %>
16
+ on <%%= pending.created_at.to_date %>
17
+ </p>
18
+
19
+ <table>
20
+ <thead>
21
+ <tr><th>Field</th><th>From</th><th>To</th></tr>
22
+ </thead>
23
+ <tbody>
24
+ <%% pending.preview.each do |field, change| %>
25
+ <tr>
26
+ <td><%%= field %></td>
27
+ <td><%%= change["from"].nil? ? "—" : change["from"] %></td>
28
+ <td><%%= change["to"] %></td>
29
+ </tr>
30
+ <%% end %>
31
+ </tbody>
32
+ </table>
33
+
34
+ <%%= button_to "Approve", approve_admin_pending_action_path(pending), form: { style: "display:inline" } %>
35
+ <%%= form_with url: reject_admin_pending_action_path(pending), method: :post, class: "reject" do %>
36
+ <%%= text_field_tag :reason, nil, placeholder: "Reason (optional)" %>
37
+ <%%= submit_tag "Reject" %>
38
+ <%% end %>
39
+ </section>
40
+ <%% end %>