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,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,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,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
|