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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
class BaseController < ActionController::Base
|
|
3
|
+
include Pagination
|
|
4
|
+
|
|
5
|
+
layout "admin"
|
|
6
|
+
|
|
7
|
+
before_action :set_loam_context
|
|
8
|
+
before_action :set_locale
|
|
9
|
+
|
|
10
|
+
rescue_from Loam::NotAuthorizedError do
|
|
11
|
+
render plain: "403 Forbidden — your role does not permit this action.", status: :forbidden
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A disabled feature is "not here" for this tenant, so it 404s — unlike a
|
|
15
|
+
# policy refusal (403), which is "you may not". Capability vs. person.
|
|
16
|
+
rescue_from Loam::FeatureDisabledError do
|
|
17
|
+
render plain: "404 Not Found — this feature is not enabled for your tenant.", status: :not_found
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A business rule (or any code) can veto a workflow transition from
|
|
21
|
+
# `loam_perform_transition!`. Surface it as a clean 422, never a 500.
|
|
22
|
+
rescue_from Loam::TransitionVetoedError do |error|
|
|
23
|
+
render plain: "422 — #{error.message}", status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
helper_method :current_tenant, :current_actor, :unread_notification_count, :feature_on?, :pending_approval_count, :current_role, :current_locale, :sort_link, :can?
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# A clickable, stateful column header for the generated index tables (L-401):
|
|
31
|
+
# toggles asc/desc, shows the active-direction arrow, and carries the current
|
|
32
|
+
# search / filter / saved-view state so sorting composes with paging and
|
|
33
|
+
# filtering. The controller whitelists the column against real columns, so a
|
|
34
|
+
# crafted `sort` param can never reach ORDER BY.
|
|
35
|
+
def sort_link(column, label)
|
|
36
|
+
active = params[:sort].to_s == column.to_s
|
|
37
|
+
ascending = active && params[:dir].to_s.casecmp("asc").zero?
|
|
38
|
+
arrow = active ? (ascending ? " ↑" : " ↓") : ""
|
|
39
|
+
carried = respond_to?(:current_index_params, true) ? current_index_params : {}
|
|
40
|
+
helpers.link_to("#{label}#{arrow}", url_for(carried.merge(sort: column, dir: ascending ? "desc" : "asc")))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Locale is request state like the tenant: from the switcher (param),
|
|
44
|
+
# remembered in the session, defaulting to Loam.default_locale. Content
|
|
45
|
+
# translations (Loam::Translatable) overlay onto it.
|
|
46
|
+
def set_locale
|
|
47
|
+
session[:locale] = params[:locale] if params[:locale].present? && Loam.locales.include?(params[:locale])
|
|
48
|
+
Loam::Current.locale = session[:locale] || Loam.default_locale
|
|
49
|
+
# Drive Rails i18n (UI strings) off the same choice, so the switcher moves
|
|
50
|
+
# both the chrome and the content — but only to a locale that actually has
|
|
51
|
+
# translations, so an unlisted locale keeps English UI (data still overlays).
|
|
52
|
+
chosen = Loam::Current.locale.to_s
|
|
53
|
+
I18n.locale = chosen if I18n.available_locales.map(&:to_s).include?(chosen)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def current_locale = Loam::Current.locale
|
|
57
|
+
|
|
58
|
+
def current_tenant = Loam::Current.tenant
|
|
59
|
+
def current_actor = Loam::Current.actor
|
|
60
|
+
|
|
61
|
+
# Establishes Loam::Current from the session, then proves the actor is
|
|
62
|
+
# allowed to be here.
|
|
63
|
+
#
|
|
64
|
+
# The order is load-bearing. Loam::Membership is itself tenant-scoped, so
|
|
65
|
+
# "is this actor a member?" is a question you can only ask from inside a
|
|
66
|
+
# tenant — the tenant has to be in Loam::Current first, or the check raises
|
|
67
|
+
# Loam::MissingTenantError instead of answering. The `&&` chain below
|
|
68
|
+
# guarantees that: membership is only consulted once both are set.
|
|
69
|
+
#
|
|
70
|
+
# If anything fails, BOTH halves of the context are cleared before the
|
|
71
|
+
# redirect, so a half-established context can never leak into the next
|
|
72
|
+
# request or into a job enqueued from it.
|
|
73
|
+
def set_loam_context
|
|
74
|
+
Loam::Current.tenant = Loam::Tenant.find_by(id: session[:tenant_id])
|
|
75
|
+
Loam::Current.actor = User.find_by(id: session[:user_id])
|
|
76
|
+
|
|
77
|
+
return if current_tenant && current_actor && member_of_current_tenant?
|
|
78
|
+
|
|
79
|
+
Loam::Current.reset
|
|
80
|
+
redirect_to new_admin_session_path
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Scoped to the current tenant by Loam::TenantRecord, which is the whole
|
|
84
|
+
# point: a membership in some other tenant is not membership here.
|
|
85
|
+
def member_of_current_tenant?
|
|
86
|
+
Loam::Membership.exists?(user_id: current_actor.id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Drives the bell in the admin layout. One COUNT per admin page render,
|
|
90
|
+
# which is fine at this scale; cache it if a screen ever gets hot.
|
|
91
|
+
def unread_notification_count
|
|
92
|
+
return 0 unless current_actor
|
|
93
|
+
|
|
94
|
+
Loam::Notification.unread.where(user_id: current_actor.id).count
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Staged changes awaiting review, for the "Approvals (N)" nav badge.
|
|
98
|
+
def pending_approval_count
|
|
99
|
+
return 0 unless current_tenant
|
|
100
|
+
|
|
101
|
+
Loam::PendingAction.pending.count
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def policy_for(record)
|
|
105
|
+
Loam::Policy.for(record)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def authorize!(policy, action)
|
|
109
|
+
raise Loam::NotAuthorizedError unless policy.public_send(action)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# For admin screens with no per-record policy (e.g. field definitions,
|
|
113
|
+
# which apply to a whole entity_type rather than one record).
|
|
114
|
+
def current_role
|
|
115
|
+
Loam::Membership.find_by(user_id: current_actor&.id)&.role&.to_sym
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def require_role!(*roles)
|
|
119
|
+
raise Loam::NotAuthorizedError unless roles.include?(current_role)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Feature-string permission gate (Loam::Permissions) — a finer capability
|
|
123
|
+
# check than require_role!. `can?` is a helper_method so views hide UI a role
|
|
124
|
+
# lacks; require_permission! enforces it (403) server-side.
|
|
125
|
+
def can?(permission)
|
|
126
|
+
Loam.can?(permission)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def require_permission!(permission)
|
|
130
|
+
raise Loam::NotAuthorizedError unless Loam.can?(permission)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Feature guards. These gate a CAPABILITY (is the feature on for this
|
|
134
|
+
# tenant), orthogonal to require_role! / policies, which gate a PERSON.
|
|
135
|
+
# `feature_on?` is a helper_method, so views can hide UI a flag turns off.
|
|
136
|
+
def feature_on?(name)
|
|
137
|
+
Loam::Features.on?(name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def require_feature!(name)
|
|
141
|
+
raise Loam::FeatureDisabledError unless Loam::Features.on?(name)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Optimistic-locking conflict handling: someone saved this record between the
|
|
145
|
+
# user opening the edit form and submitting it (lock_version mismatch →
|
|
146
|
+
# StaleObjectError). Build a field-level diff of what the user tried to write
|
|
147
|
+
# vs the current saved values, then RELOAD to fresh data so the retry carries
|
|
148
|
+
# the new lock_version — never a 500, never a silent clobber. lock_version is
|
|
149
|
+
# the guarantee; the advisory Loam::RecordLock is only a courtesy warning.
|
|
150
|
+
#
|
|
151
|
+
# Encrypted fields are compared and shown via the reader (decrypted); the raw
|
|
152
|
+
# column is ciphertext that never matches (random IV), so a byte compare would
|
|
153
|
+
# be a false conflict every time.
|
|
154
|
+
def stale_conflict!(record, fields)
|
|
155
|
+
encrypted = record.class.respond_to?(:loam_encrypted_attributes) ? record.class.loam_encrypted_attributes.map(&:to_s) : []
|
|
156
|
+
attempted = fields.map(&:to_s).index_with { |field| conflict_value(record, field, encrypted) }
|
|
157
|
+
|
|
158
|
+
record.reload
|
|
159
|
+
|
|
160
|
+
@conflict = fields.map(&:to_s).each_with_object({}) do |field, diff|
|
|
161
|
+
theirs = conflict_value(record, field, encrypted)
|
|
162
|
+
yours = attempted[field]
|
|
163
|
+
diff[field] = { "yours" => yours, "theirs" => theirs } unless yours.to_s == theirs.to_s
|
|
164
|
+
end
|
|
165
|
+
flash.now[:alert] = "This #{record.model_name.human.downcase} changed since you opened it — review the differences and save again."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def conflict_value(record, field, encrypted)
|
|
169
|
+
encrypted.include?(field) ? record.public_send(field) : record[field]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Step-up ("sudo") auth. Orthogonal to role: even a manager re-confirms for a
|
|
173
|
+
# sensitive action if their last authentication was more than SUDO_WINDOW ago.
|
|
174
|
+
# A fresh login or MFA verification stamps session[:sudo_at]; this re-checks
|
|
175
|
+
# it and, when stale, detours through the re-challenge and comes back.
|
|
176
|
+
SUDO_WINDOW = 5.minutes
|
|
177
|
+
|
|
178
|
+
def require_sudo!
|
|
179
|
+
return if sudo_fresh?
|
|
180
|
+
|
|
181
|
+
# A GET can be replayed after re-auth; a non-GET (a DELETE button) cannot,
|
|
182
|
+
# so we come back to the page it was on and the user repeats the action.
|
|
183
|
+
session[:sudo_return_to] = request.get? ? request.fullpath : request.referer
|
|
184
|
+
redirect_to new_admin_sudo_path
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def sudo_fresh?
|
|
188
|
+
authenticated_at = session[:sudo_at]
|
|
189
|
+
authenticated_at.present? && Time.now.to_i - authenticated_at.to_i < SUDO_WINDOW.to_i
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Attaching a file changes the record, so it is an update: a role that may
|
|
193
|
+
# not update this record may not put files on it either.
|
|
194
|
+
#
|
|
195
|
+
# A `multiple: true` file field posts an empty string alongside any real
|
|
196
|
+
# files, so blanks are dropped BEFORE the authorization check — submitting
|
|
197
|
+
# a form with no file chosen is not an attempt to upload.
|
|
198
|
+
def attach_files!(record, policy)
|
|
199
|
+
submitted = Array(params.dig(record.model_name.param_key, :files)).reject(&:blank?)
|
|
200
|
+
return if submitted.empty?
|
|
201
|
+
|
|
202
|
+
raise Loam::NotAuthorizedError unless policy.update?
|
|
203
|
+
|
|
204
|
+
record.files.attach(submitted)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Runtime custom fields (see Loam::CustomFields) go through the same
|
|
208
|
+
# field-level enforcement as real columns: only a writable definition's
|
|
209
|
+
# value is ever assigned.
|
|
210
|
+
def assign_custom_fields!(record, params, policy)
|
|
211
|
+
# The form partial nests inputs under the model's param key
|
|
212
|
+
# (equipment[custom_fields][serial_number]), so the values must be read
|
|
213
|
+
# from there — a top-level params[:custom_fields] read silently saves
|
|
214
|
+
# nothing while the redirect still says success. Found by an agent
|
|
215
|
+
# during the first golden-tasks benchmark run.
|
|
216
|
+
submitted = params.dig(record.model_name.param_key, :custom_fields)
|
|
217
|
+
return unless submitted
|
|
218
|
+
|
|
219
|
+
submitted.each do |name, value|
|
|
220
|
+
record.set_custom_field(name, value) if policy.custom_field_writable?(name)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Managing business rules (Loam::BusinessRule) — manager-only, since a rule can
|
|
3
|
+
# notify, emit events, and set fields across the tenant. The condition and
|
|
4
|
+
# actions are edited as JSON for the prototype (a visual builder is a future
|
|
5
|
+
# increment). The recent execution log shows WHY rules fired.
|
|
6
|
+
class BusinessRulesController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
before_action :set_rule, only: %i[edit update destroy]
|
|
9
|
+
|
|
10
|
+
rescue_from JSON::ParserError do |error|
|
|
11
|
+
flash.now[:alert] = "Condition and actions must be valid JSON: #{error.message}"
|
|
12
|
+
@rule ||= Loam::BusinessRule.new
|
|
13
|
+
render(@rule.persisted? ? :edit : :new, status: :unprocessable_entity)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def index
|
|
17
|
+
@rules = Loam::BusinessRule.by_priority
|
|
18
|
+
@recent_runs = Loam::BusinessRuleRun.recent.limit(20)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def new
|
|
22
|
+
@rule = Loam::BusinessRule.new(active: true, condition: {}, actions: [])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create
|
|
26
|
+
@rule = Loam::BusinessRule.new(rule_params)
|
|
27
|
+
if @rule.save
|
|
28
|
+
redirect_to admin_business_rules_path, notice: "Rule created."
|
|
29
|
+
else
|
|
30
|
+
render :new, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit; end
|
|
35
|
+
|
|
36
|
+
def update
|
|
37
|
+
if @rule.update(rule_params)
|
|
38
|
+
redirect_to admin_business_rules_path, notice: "Rule updated."
|
|
39
|
+
else
|
|
40
|
+
render :edit, status: :unprocessable_entity
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy
|
|
45
|
+
@rule.destroy!
|
|
46
|
+
redirect_to admin_business_rules_path, notice: "Rule deleted."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def set_rule
|
|
52
|
+
@rule = Loam::BusinessRule.find(params[:id])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parse the JSON textareas here; a malformed value raises JSON::ParserError,
|
|
56
|
+
# which the rescue above turns into a form error (never a 500).
|
|
57
|
+
def rule_params
|
|
58
|
+
attributes = params.require(:business_rule).permit(:name, :entity_type, :trigger, :active, :priority)
|
|
59
|
+
attributes[:condition] = JSON.parse(params[:business_rule][:condition].presence || "{}")
|
|
60
|
+
attributes[:actions] = JSON.parse(params[:business_rule][:actions].presence || "[]")
|
|
61
|
+
attributes
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%% if @error || flash[:alert] %>
|
|
2
|
+
<p style="color:#b00"><%%= @error || flash[:alert] %></p>
|
|
3
|
+
<%% end %>
|
|
4
|
+
|
|
5
|
+
<%# The model is namespaced (Loam::BusinessRule), so an inferred URL/param-scope
|
|
6
|
+
would be admin_loam_business_rule_path / loam_business_rule[...]. Pin both to
|
|
7
|
+
the un-namespaced route and the "business_rule" key the controller permits. %>
|
|
8
|
+
<%%= form_with model: @rule, scope: :business_rule,
|
|
9
|
+
url: (@rule.persisted? ? admin_business_rule_path(@rule) : admin_business_rules_path) do |f| %>
|
|
10
|
+
<%% if @rule.errors.any? %>
|
|
11
|
+
<ul><%% @rule.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
|
|
12
|
+
<%% end %>
|
|
13
|
+
|
|
14
|
+
<p><%%= f.label :name %><br><%%= f.text_field :name %></p>
|
|
15
|
+
<p><%%= f.label :trigger, "Trigger (event pattern, e.g. billing.invoice.submit)" %><br><%%= f.text_field :trigger %></p>
|
|
16
|
+
<p><%%= f.label :entity_type, "Entity type (e.g. Invoice; blank for event-only)" %><br><%%= f.text_field :entity_type %></p>
|
|
17
|
+
<p><%%= f.label :priority %><br><%%= f.number_field :priority %></p>
|
|
18
|
+
<p><%%= f.label :active %> <%%= f.check_box :active %></p>
|
|
19
|
+
|
|
20
|
+
<p>
|
|
21
|
+
<%%= f.label :condition, "Condition (JSON)" %><br>
|
|
22
|
+
<%%= f.text_area :condition, value: JSON.pretty_generate(@rule.condition_tree), rows: 6, cols: 60 %>
|
|
23
|
+
</p>
|
|
24
|
+
<p>
|
|
25
|
+
<%%= f.label :actions, "Actions (JSON list)" %><br>
|
|
26
|
+
<%%= f.text_area :actions, value: JSON.pretty_generate(@rule.action_list), rows: 6, cols: 60 %>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<%%= f.submit %>
|
|
30
|
+
<%% end %>
|
|
31
|
+
|
|
32
|
+
<p><%%= link_to "Back", admin_business_rules_path %></p>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<h1>Business rules</h1>
|
|
2
|
+
|
|
3
|
+
<p><%%= link_to "New rule", new_admin_business_rule_path %></p>
|
|
4
|
+
|
|
5
|
+
<table>
|
|
6
|
+
<thead>
|
|
7
|
+
<tr><th>Name</th><th>Trigger</th><th>Entity</th><th>Priority</th><th>Active</th><th></th></tr>
|
|
8
|
+
</thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<%% @rules.each do |rule| %>
|
|
11
|
+
<tr>
|
|
12
|
+
<td><%%= rule.name %></td>
|
|
13
|
+
<td><code><%%= rule.trigger %></code></td>
|
|
14
|
+
<td><%%= rule.entity_type %></td>
|
|
15
|
+
<td><%%= rule.priority %></td>
|
|
16
|
+
<td><%%= rule.active? ? "yes" : "no" %></td>
|
|
17
|
+
<td>
|
|
18
|
+
<%%= link_to "Edit", edit_admin_business_rule_path(rule) %>
|
|
19
|
+
<%%= button_to "Delete", admin_business_rule_path(rule), method: :delete, form: { style: "display:inline" } %>
|
|
20
|
+
</td>
|
|
21
|
+
</tr>
|
|
22
|
+
<%% end %>
|
|
23
|
+
</tbody>
|
|
24
|
+
</table>
|
|
25
|
+
|
|
26
|
+
<h2>Recent runs</h2>
|
|
27
|
+
<p>Why rules did (or didn't) act — the last 20.</p>
|
|
28
|
+
<table>
|
|
29
|
+
<thead>
|
|
30
|
+
<tr><th>When</th><th>Rule</th><th>Subject</th><th>Event</th><th>Matched</th><th>Actions</th><th>Error</th></tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
<%% @recent_runs.each do |run| %>
|
|
34
|
+
<tr>
|
|
35
|
+
<td><%%= run.created_at.strftime("%H:%M:%S") %></td>
|
|
36
|
+
<td><%%= run.business_rule.name %></td>
|
|
37
|
+
<td><%%= [run.subject_type, run.subject_id].compact.join(" #") %></td>
|
|
38
|
+
<td><code><%%= run.event_name %></code></td>
|
|
39
|
+
<td><%%= run.matched ? "yes" : "no" %></td>
|
|
40
|
+
<td><%%= Array(run.actions_taken).join(", ") %></td>
|
|
41
|
+
<td><%%= run.error %></td>
|
|
42
|
+
</tr>
|
|
43
|
+
<%% end %>
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# One controller for every commentable entity, because a comment form is the
|
|
3
|
+
# same form whatever it is attached to.
|
|
4
|
+
#
|
|
5
|
+
# Create only: comments are a record of what was said, so there is no edit
|
|
6
|
+
# and no delete here.
|
|
7
|
+
class CommentsController < BaseController
|
|
8
|
+
def create
|
|
9
|
+
record = commentable
|
|
10
|
+
|
|
11
|
+
# Commenting is open to any member — and membership is already proven:
|
|
12
|
+
# BaseController refuses the request otherwise. The policy check is the
|
|
13
|
+
# record's own, so an entity that hides itself from a role hides its
|
|
14
|
+
# discussion too.
|
|
15
|
+
authorize!(policy_for(record), :read?)
|
|
16
|
+
|
|
17
|
+
comment = record.loam_comments.new(body: params[:body], author: current_actor)
|
|
18
|
+
flash[:alert] = comment.errors.full_messages.to_sentence unless comment.save
|
|
19
|
+
|
|
20
|
+
redirect_back fallback_location: [ :admin, record ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The type arrives from the browser, so it is never constantized blindly.
|
|
26
|
+
# `safe_constantize` returns nil for anything that is not a real constant,
|
|
27
|
+
# and the class then has to prove it is a Loam entity that opted into
|
|
28
|
+
# comments — the allowlist is the contract, not a hand-kept list of names.
|
|
29
|
+
def commentable
|
|
30
|
+
klass = params[:commentable_type].to_s.safe_constantize
|
|
31
|
+
|
|
32
|
+
unless klass.is_a?(Class) && klass < Loam::TenantRecord && klass.include?(Loam::Commentable)
|
|
33
|
+
raise Loam::NotAuthorizedError, "#{params[:commentable_type].inspect} is not a commentable Loam entity"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
klass.find(params[:commentable_id])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Per-tenant settings (Loam::Configs). Structural, not per-record, so it is
|
|
3
|
+
# gated on the manager role rather than an entity policy — settings are
|
|
4
|
+
# privileged. Everything routes through Loam::Configs, never Loam::Config
|
|
5
|
+
# directly, so the cross-level resolution stays in vetted gem code.
|
|
6
|
+
class ConfigsController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
# Feature flags share the loam_configs store but have their own screen
|
|
11
|
+
# (/admin/features), so hide their reserved-prefix keys from Settings.
|
|
12
|
+
keys = Loam::Configs.defined_keys.reject { |key| key.start_with?(Loam::Features::PREFIX) }
|
|
13
|
+
@settings = keys.map do |key|
|
|
14
|
+
{ key: key, value: Loam::Configs.get(key), overridden: Loam::Configs.overridden?(key) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def edit
|
|
19
|
+
@key = params[:key]
|
|
20
|
+
@value = Loam::Configs.get(@key)
|
|
21
|
+
@overridden = Loam::Configs.overridden?(@key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update
|
|
25
|
+
Loam::Configs.set(params[:key], coerce(params[:value]))
|
|
26
|
+
redirect_to admin_configs_path, notice: "#{params[:key]} set for this tenant."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset
|
|
30
|
+
Loam::Configs.reset(params[:key])
|
|
31
|
+
redirect_to admin_configs_path, notice: "#{params[:key]} reset to the default."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# The form is a single text field, so values arrive as strings. Parse them
|
|
37
|
+
# as JSON so numbers/booleans/objects keep their type; anything that is not
|
|
38
|
+
# valid JSON (a bare word like PLN) is stored as the string it is. Known
|
|
39
|
+
# quirk: typing 25 stores the number, never the string "25" — fine here.
|
|
40
|
+
def coerce(raw)
|
|
41
|
+
JSON.parse(raw)
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
raw
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<h1>Setting: <%%= @key %></h1>
|
|
2
|
+
|
|
3
|
+
<p>Overriding for <strong><%%= current_tenant.name %></strong>.
|
|
4
|
+
Current value: <code><%%= @value.inspect %></code>
|
|
5
|
+
(<%%= @overridden ? "overridden here" : "inherited default" %>).</p>
|
|
6
|
+
|
|
7
|
+
<%%= form_with url: admin_configs_path, method: :patch do |f| %>
|
|
8
|
+
<%%= hidden_field_tag :key, @key %>
|
|
9
|
+
<p>
|
|
10
|
+
<%%= label_tag :value, "Value" %><br>
|
|
11
|
+
<%%= text_field_tag :value, @value.is_a?(String) ? @value : @value.to_json %>
|
|
12
|
+
</p>
|
|
13
|
+
<p class="hint">Numbers, true/false and JSON objects keep their type; plain text is stored as a string.</p>
|
|
14
|
+
<%%= f.submit "Save override" %>
|
|
15
|
+
<%% end %>
|
|
16
|
+
|
|
17
|
+
<p>
|
|
18
|
+
<%% if @overridden %>
|
|
19
|
+
<%%= button_to "Reset to default", admin_configs_path(key: @key), method: :delete %>
|
|
20
|
+
<%% end %>
|
|
21
|
+
<%%= link_to "Back", admin_configs_path %>
|
|
22
|
+
</p>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<h1>Settings</h1>
|
|
2
|
+
|
|
3
|
+
<p>Values resolve override → global → declared default. Editing sets an override
|
|
4
|
+
for <strong><%%= current_tenant.name %></strong> only; Reset drops it.</p>
|
|
5
|
+
|
|
6
|
+
<table>
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>
|
|
9
|
+
<th>Key</th>
|
|
10
|
+
<th>Value (this tenant)</th>
|
|
11
|
+
<th>Source</th>
|
|
12
|
+
<th></th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody>
|
|
16
|
+
<%% @settings.each do |setting| %>
|
|
17
|
+
<tr>
|
|
18
|
+
<td><%%= setting[:key] %></td>
|
|
19
|
+
<td><%%= setting[:value].inspect %></td>
|
|
20
|
+
<td><%%= setting[:overridden] ? "override" : "default" %></td>
|
|
21
|
+
<td>
|
|
22
|
+
<%%= link_to "Edit", admin_edit_config_path(key: setting[:key]) %>
|
|
23
|
+
<%% if setting[:overridden] %>
|
|
24
|
+
<%%= button_to "Reset", admin_configs_path(key: setting[:key]), method: :delete, form: { style: "display:inline" } %>
|
|
25
|
+
<%% end %>
|
|
26
|
+
</td>
|
|
27
|
+
</tr>
|
|
28
|
+
<%% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
class DashboardController < BaseController
|
|
3
|
+
def index
|
|
4
|
+
# Configurable, role-visible widgets (Loam::Dashboard) — a raising widget
|
|
5
|
+
# is isolated into an error tile, never breaking the page. Register your
|
|
6
|
+
# own with Loam::Widgets.register; arrange them under Dashboard settings.
|
|
7
|
+
@widgets = Loam::Dashboard.for(actor: current_actor, role: current_role)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<h1>Dashboard</h1>
|
|
2
|
+
|
|
3
|
+
<%% if current_role == :manager %>
|
|
4
|
+
<p><%%= link_to "Dashboard settings", admin_dashboard_widgets_path %></p>
|
|
5
|
+
<%% end %>
|
|
6
|
+
|
|
7
|
+
<div class="widgets" style="display:flex; flex-wrap:wrap; gap:1rem">
|
|
8
|
+
<%% @widgets.each do |widget| %>
|
|
9
|
+
<div class="widget" style="border:1px solid #ccc; padding:0.75rem; min-width:200px">
|
|
10
|
+
<h3><%%= widget[:title] %></h3>
|
|
11
|
+
<%% if widget[:error] %>
|
|
12
|
+
<p style="color:#b00">This widget could not load.</p>
|
|
13
|
+
<%% else %>
|
|
14
|
+
<%% data = widget[:data] %>
|
|
15
|
+
<%% case data[:kind] %>
|
|
16
|
+
<%% when "count" %>
|
|
17
|
+
<p style="font-size:2rem; margin:0"><%%= data[:value] %></p>
|
|
18
|
+
<%% when "list" %>
|
|
19
|
+
<ul><%% data[:items].each do |item| %><li><%%= item %></li><%% end %></ul>
|
|
20
|
+
<%% else %>
|
|
21
|
+
<p><%%= data.inspect %></p>
|
|
22
|
+
<%% end %>
|
|
23
|
+
<%% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<%% end %>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Dashboard settings (Loam::DashboardWidget) — manager-only. Choose which
|
|
3
|
+
# registered widgets appear and in what order for this tenant.
|
|
4
|
+
class DashboardWidgetsController < BaseController
|
|
5
|
+
before_action { require_role!(:manager) }
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@registered = Loam::Widgets.registered
|
|
9
|
+
@config = Loam::DashboardWidget.all.index_by(&:widget_key)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
Loam::Widgets.keys.each do |key|
|
|
14
|
+
attrs = params.dig(:widgets, key) || {}
|
|
15
|
+
row = Loam::DashboardWidget.find_or_initialize_by(widget_key: key)
|
|
16
|
+
row.active = attrs[:active] == "1"
|
|
17
|
+
row.position = attrs[:position].to_i
|
|
18
|
+
row.save!
|
|
19
|
+
end
|
|
20
|
+
redirect_to admin_root_path, notice: "Dashboard updated."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<h1>Dashboard settings</h1>
|
|
2
|
+
|
|
3
|
+
<p>Choose which widgets appear on the dashboard and their order (lower position
|
|
4
|
+
first). Leaving none active shows the default set.</p>
|
|
5
|
+
|
|
6
|
+
<%%= form_with url: admin_dashboard_widgets_path, method: :patch do %>
|
|
7
|
+
<table>
|
|
8
|
+
<thead><tr><th>Show</th><th>Widget</th><th>Roles</th><th>Position</th></tr></thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<%% @registered.each do |widget| %>
|
|
11
|
+
<%% row = @config[widget.key] %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><%%= check_box_tag "widgets[#{widget.key}][active]", "1", row.nil? || row.active %></td>
|
|
14
|
+
<td><%%= widget.title %> <small>(<code><%%= widget.key %></code>)</small></td>
|
|
15
|
+
<td><%%= widget.roles ? Array(widget.roles).join(", ") : "all" %></td>
|
|
16
|
+
<td><%%= number_field_tag "widgets[#{widget.key}][position]", row&.position || 0, style: "width:4rem" %></td>
|
|
17
|
+
</tr>
|
|
18
|
+
<%% end %>
|
|
19
|
+
</tbody>
|
|
20
|
+
</table>
|
|
21
|
+
<%%= submit_tag "Save dashboard" %>
|
|
22
|
+
<%% end %>
|
|
23
|
+
|
|
24
|
+
<p><%%= link_to "Back to dashboard", admin_root_path %></p>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Admin
|
|
2
|
+
# Managed lookup lists (Loam::Dictionary) — manager-only. The index lists a
|
|
3
|
+
# tenant's dictionaries; the edit screen curates a dictionary's entries
|
|
4
|
+
# (Admin::DictionaryEntriesController). Tenant-scoped by the model's default
|
|
5
|
+
# scope, so a manager only ever sees their own tenant's lists.
|
|
6
|
+
class DictionariesController < BaseController
|
|
7
|
+
before_action { require_role!(:manager) }
|
|
8
|
+
before_action :set_dictionary, only: %i[edit update destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@dictionaries = Loam::Dictionary.order(:key)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@dictionary = Loam::Dictionary.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@dictionary = Loam::Dictionary.new(dictionary_params)
|
|
20
|
+
if @dictionary.save
|
|
21
|
+
redirect_to edit_admin_dictionary_path(@dictionary), notice: "Dictionary created — add its entries below."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def edit
|
|
28
|
+
@entry = @dictionary.entries.new(active: true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update
|
|
32
|
+
if @dictionary.update(dictionary_params)
|
|
33
|
+
redirect_to admin_dictionaries_path, notice: "Dictionary updated."
|
|
34
|
+
else
|
|
35
|
+
@entry = @dictionary.entries.new
|
|
36
|
+
render :edit, status: :unprocessable_entity
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy
|
|
41
|
+
@dictionary.destroy!
|
|
42
|
+
redirect_to admin_dictionaries_path, notice: "Dictionary deleted."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def set_dictionary
|
|
48
|
+
@dictionary = Loam::Dictionary.find(params[:id])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dictionary_params
|
|
52
|
+
params.require(:dictionary).permit(:key, :name)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|