collavre 0.20.3 → 0.22.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class AgentChannel < ApplicationCable::Channel
5
+ # Heartbeat for Claude Channel presence rows. Fires for every subscription,
6
+ # but touch_presence is a no-op unless this connection registered a presence
7
+ # row (agent_id subscribe by a Claude Channel agent), so topic/legacy/non-
8
+ # Claude subscribers pay only an idle timer.
9
+ periodically :touch_presence, every: AgentSubscription::HEARTBEAT_SECONDS
10
+
11
+ # Subscribes to an agent stream for real-time dispatch notifications.
12
+ # Accepts either:
13
+ # - agent_id: per-agent stream — used by MCP plugin clients (Claude
14
+ # Channel) so they receive every dispatch routed to the agent, no
15
+ # matter which topic triggered it. Authorized by created_by ownership.
16
+ # - topic_id: per-topic stream — legacy/UI listeners scoped to one topic.
17
+ def subscribed
18
+ return reject unless current_user
19
+
20
+ if params[:agent_id].present?
21
+ subscribe_to_agent_stream
22
+ elsif params[:topic_id].present?
23
+ subscribe_to_topic_stream
24
+ else
25
+ reject
26
+ end
27
+ end
28
+
29
+ # When the MCP process crashes or the WebSocket drops before the plugin
30
+ # can call DELETE /api/v1/agent/:id, a Claude Channel session would
31
+ # otherwise stay matchable (routing_expression: "true") and any future
32
+ # comment on a creative still shared with the agent would dispatch into an
33
+ # empty stream — delegated work that only clears via stuck recovery.
34
+ #
35
+ # One shared agent can have MANY concurrent sessions, so routing is gated
36
+ # on PRESENCE: this session drops its own AgentSubscription row, and
37
+ # routing is cleared only when NO rows remain. A still-live sibling session
38
+ # keeps its row, so routing stays active and its in-flight work is never
39
+ # cancelled. Done under the agent's row lock so a concurrent subscribe on
40
+ # the same agent serializes (no clear-vs-activate race).
41
+ #
42
+ # Cross-process / late unsubscribe: the row was keyed by this connection's
43
+ # own @subscription_token. A stale unsubscribe whose row was already
44
+ # removed (newer subscribe took over, or a different Puma/Kamal process)
45
+ # deletes zero rows and becomes a no-op — required for scaled deployments
46
+ # (WEB_CONCURRENCY > 1, Solid Cable).
47
+ def unsubscribed
48
+ return unless @session_agent && @subscription_token
49
+
50
+ cleared = false
51
+ dropped_session_id = nil
52
+ @session_agent.with_lock do
53
+ scope = AgentSubscription.where(agent_id: @session_agent.id, token: @subscription_token)
54
+ # Capture the dropped session's id before deleting its row so a live-
55
+ # sibling exit can still scope a cleanup to this session's own topic.
56
+ dropped_session_id = scope.pick(:session_id)
57
+ deleted = scope.delete_all
58
+ # Stale: our presence row is already gone. The live owner's lifecycle
59
+ # owns routing — do not clobber it, do not schedule cancellation.
60
+ if deleted.zero?
61
+ dropped_session_id = nil
62
+ next
63
+ end
64
+
65
+ # Drop crash-orphaned sibling rows (a Puma/ActionCable process that
66
+ # died without firing unsubscribed) before reading presence, so a dead
67
+ # row cannot masquerade as a live sibling and pin routing on forever.
68
+ AgentSubscription.reap_stale!(@session_agent.id)
69
+
70
+ # Another session is still LIVE on this shared agent. Keep routing
71
+ # active; whichever session unsubscribes last clears it.
72
+ next if AgentSubscription.live.where(agent_id: @session_agent.id).exists?
73
+
74
+ @session_agent.update_columns(
75
+ routing_expression: nil,
76
+ routing_subscription_token: nil
77
+ )
78
+ cleared = true
79
+ end
80
+
81
+ if cleared
82
+ # Reconnect-grace cancellation (last session): clearing routing only
83
+ # makes the agent unroutable. Any task already "delegated" still holds
84
+ # its ResourceTracker slot — the dispatch was broadcast to a now-dead
85
+ # stream so no client remains to call /reply, and the slot would stay
86
+ # held until StuckDetectorJob times out. The job cancels those tasks
87
+ # after a grace window, but only if the agent is still offline (it
88
+ # rechecks routing_expression AND that no session has resubscribed).
89
+ CancelOfflineDelegatedTasksJob
90
+ .set(wait: CancelOfflineDelegatedTasksJob::GRACE_SECONDS.seconds)
91
+ .perform_later(@session_agent.id, @subscription_token)
92
+ elsif dropped_session_id.present?
93
+ # A live sibling keeps the shared agent routable, so we must NOT clear
94
+ # routing or sweep agent-wide. But this dropped session's OWN session
95
+ # topic is private to it — siblings filter session_topic dispatches to
96
+ # their own topic, so none will /reply to a task delegated there and it
97
+ # would hold its slot until stuck recovery. Schedule a grace-delayed,
98
+ # session-scoped cancellation (skipped if this same session reconnects
99
+ # within the window), mirroring the destroy path's cancel_tasks_for_topic.
100
+ CancelOfflineDelegatedTasksJob
101
+ .set(wait: CancelOfflineDelegatedTasksJob::GRACE_SECONDS.seconds)
102
+ .perform_later(@session_agent.id, @subscription_token, dropped_session_id)
103
+ end
104
+ rescue ActiveRecord::StatementInvalid => e
105
+ Rails.logger.warn("[AgentChannel] unsubscribed presence clear failed: #{e.message}")
106
+ end
107
+
108
+ # Pull-on-resubscribe replay (Codex P2): after every (re)subscribe the plugin
109
+ # sends the permission request_ids it still holds pending, and the server
110
+ # re-broadcasts the recorded decision for exactly those ids. A decision
111
+ # broadcast once into the transient agent:user:<id> stream while the plugin's
112
+ # WebSocket was down would otherwise be lost (action_executed_at already
113
+ # stamped, buttons hidden), hanging the suspended tool with no retry path.
114
+ # The plugin's pending set is the sole bound — there is no wall-clock window,
115
+ # so an outage of any length is covered, and a decision already consumed is
116
+ # never requested. Idempotent: the plugin's coordinator drops any id it no
117
+ # longer holds. Gated on @session_agent so only a Claude Channel agent
118
+ # subscription (where stream_from agent:user:<id> is attached) can pull.
119
+ def replay_permissions(data)
120
+ return unless @session_agent
121
+
122
+ Comment.replay_claude_channel_permission_decisions_for(
123
+ @session_agent.id,
124
+ data["request_ids"]
125
+ )
126
+ end
127
+
128
+ # Broadcast an arbitrary payload to a topic's agent stream.
129
+ def self.broadcast_to_topic(topic_id, payload)
130
+ ActionCable.server.broadcast("agent:topic:#{topic_id}", payload)
131
+ end
132
+
133
+ # Broadcast to a per-agent stream so the agent's MCP plugin receives the
134
+ # dispatch regardless of which topic triggered it.
135
+ def self.broadcast_to_agent(agent_id, payload)
136
+ ActionCable.server.broadcast("agent:user:#{agent_id}", payload)
137
+ end
138
+
139
+ private
140
+
141
+ def subscribe_to_topic_stream
142
+ @topic = Topic.find_by(id: params[:topic_id])
143
+ return reject unless @topic
144
+
145
+ creative = @topic.creative&.effective_origin
146
+ return reject unless creative&.has_permission?(current_user, :read)
147
+
148
+ stream_from "agent:topic:#{@topic.id}"
149
+ end
150
+
151
+ def subscribe_to_agent_stream
152
+ agent = User.find_by(id: params[:agent_id])
153
+ return reject unless agent&.ai_user?
154
+ return reject unless agent.created_by_id == current_user.id
155
+
156
+ # Attach the stream BEFORE activating routing. Orchestration::Matcher
157
+ # can pick this agent as soon as routing_expression becomes "true";
158
+ # broadcasts to agent:user:<id> in the window between the UPDATE
159
+ # committing and stream_from registering the subscription would land
160
+ # in a stream with no subscriber, leaving the new delegated task
161
+ # waiting on stuck recovery. Registering the subscription first means
162
+ # by the time the agent is matchable, this connection is already
163
+ # receiving broadcasts.
164
+ @session_agent = agent if agent.claude_channel_agent?
165
+ @subscription_token = SecureRandom.hex(8) if agent.claude_channel_agent?
166
+ stream_from "agent:user:#{agent.id}"
167
+
168
+ # Record this session's presence row and (re)activate routing under the
169
+ # agent's row lock, so a concurrent unsubscribe on the same shared agent
170
+ # serializes against this and cannot clear routing out from under a live
171
+ # session. routing_subscription_token is kept as the most-recent-session
172
+ # marker (debugging / grace-job arg); presence rows are the real gate.
173
+ if agent.claude_channel_agent?
174
+ agent.with_lock do
175
+ # Self-heal: clear crash-orphaned rows for this agent before counting
176
+ # so this session's activation isn't blocked from, and presence reads
177
+ # aren't fooled by, a dead process's leftover row.
178
+ AgentSubscription.reap_stale!(agent.id)
179
+ # Record the plugin-supplied session_id (stable across --resume) on the
180
+ # row. The HTTP unregister path (DELETE /api/v1/agent/:id) cannot know
181
+ # this connection's server-minted @subscription_token, so it correlates
182
+ # the exiting session to its row via session_id instead.
183
+ AgentSubscription.create!(
184
+ agent_id: agent.id,
185
+ token: @subscription_token,
186
+ session_id: params[:session_id].presence
187
+ )
188
+ agent.update_columns(
189
+ routing_subscription_token: @subscription_token,
190
+ routing_expression: "true"
191
+ )
192
+ end
193
+ end
194
+ end
195
+
196
+ # Periodic heartbeat callback: keep this session's presence row live. No-op
197
+ # once the row is gone (rotated by a newer subscribe, or reaped), so it can
198
+ # never resurrect a removed row.
199
+ def touch_presence
200
+ return unless @session_agent && @subscription_token
201
+
202
+ AgentSubscription.touch!(@session_agent.id, @subscription_token)
203
+ end
204
+ end
205
+ end
@@ -1,5 +1,12 @@
1
1
  module Collavre
2
2
  class CommentsPresenceChannel < ApplicationCable::Channel
3
+ def self.broadcast_channel_chips_changed(creative_id, topic_id:)
4
+ ActionCable.server.broadcast(
5
+ "comments_presence:#{creative_id}",
6
+ { channel_chips: { topic_id: topic_id } }
7
+ )
8
+ end
9
+
3
10
  def self.broadcast_shares_changed(creative_id, shared_user_id:, permission: nil, action: "updated", has_access: nil, can_comment: nil, has_access_changed: nil, can_comment_changed: nil)
4
11
  ActionCable.server.broadcast(
5
12
  "comments_presence:#{creative_id}",
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Admin
5
+ class IntegrationsController < ApplicationController
6
+ before_action :require_system_admin!
7
+ before_action :load_definition!, only: [ :reset ]
8
+
9
+ Registry = Collavre::IntegrationSettings::Registry
10
+ Resolver = Collavre::IntegrationSettings::Resolver
11
+
12
+ def index
13
+ @grouped_settings = build_grouped_settings
14
+ end
15
+
16
+ def bulk_update
17
+ submitted = (params[:integration_setting] || {}).to_unsafe_h
18
+ restart_required_changed = false
19
+
20
+ Collavre::IntegrationSetting.transaction do
21
+ submitted.each do |key, raw_value|
22
+ value = raw_value.to_s
23
+ next if value.blank?
24
+
25
+ definition = Registry.instance.find(key) or next
26
+ # Mirror the index filter: keys hidden from the admin UI
27
+ # (admin_visible: false) are not editable via this form, so a crafted
28
+ # POST cannot write them.
29
+ next if definition.admin_visible == false
30
+
31
+ row = Collavre::IntegrationSetting.find_or_initialize_by(key: definition.key.to_s)
32
+ row.category = definition.category
33
+ row.value = value
34
+ row.save!
35
+
36
+ restart_required_changed ||= definition.requires_restart
37
+ end
38
+ end
39
+
40
+ flash[:notice] = t("collavre.admin.integrations.flash.updated")
41
+ flash[:warning] = t("collavre.admin.integrations.flash.restart_required") if restart_required_changed
42
+ redirect_to collavre.admin_integrations_path
43
+ end
44
+
45
+ def reset
46
+ Collavre::IntegrationSetting.where(key: @definition.key.to_s).destroy_all
47
+ redirect_to collavre.admin_integrations_path,
48
+ notice: t("collavre.admin.integrations.flash.reset_done", key: @definition.key)
49
+ end
50
+
51
+ private
52
+
53
+ def load_definition!
54
+ @definition = Registry.instance.find(params[:key])
55
+ # Keys hidden from the admin UI (admin_visible: false) are treated as
56
+ # non-addressable here too, so a crafted DELETE cannot reset them.
57
+ if @definition.nil? || @definition.admin_visible == false
58
+ @definition = nil
59
+ render file: Rails.root.join("public/404.html"), status: :not_found, layout: false and return
60
+ end
61
+ end
62
+
63
+ def build_grouped_settings
64
+ Registry.instance.by_category.sort_by { |category, _defs| category.to_s }.filter_map do |category, definitions|
65
+ visible = definitions.select { |d| d.admin_visible != false }
66
+ next if visible.empty?
67
+
68
+ [ category, visible.map { |definition| build_row(definition) } ]
69
+ end
70
+ end
71
+
72
+ def build_row(definition)
73
+ value = Resolver.get(definition.key)
74
+ source = Resolver.source_for(definition.key)
75
+ display = definition.sensitive ? mask_value(value) : value
76
+
77
+ {
78
+ definition: definition,
79
+ source: source,
80
+ display_value: display,
81
+ present: value.present?
82
+ }
83
+ end
84
+
85
+ def mask_value(value)
86
+ return nil if value.blank?
87
+ return "••••" if value.to_s.length <= 4
88
+
89
+ "••••#{value.to_s[-4..]}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -10,6 +10,7 @@ module Collavre
10
10
  @mcp_tool_approval = SystemSetting.find_by(key: "mcp_tool_approval_required")&.value == "true"
11
11
  @creatives_login_required = SystemSetting.creatives_login_required?
12
12
  @home_page_path = SystemSetting.home_page_path
13
+ @home_page_path_authenticated = SystemSetting.home_page_path_authenticated
13
14
 
14
15
  # Account lockout settings
15
16
  @max_login_attempts = SystemSetting.max_login_attempts
@@ -93,23 +94,9 @@ module Collavre
93
94
  creatives_login_setting.value = params[:creatives_login_required] == "1" ? "true" : "false"
94
95
  creatives_login_setting.save!
95
96
 
96
- # Home Page Path
97
- home_page_path_input = params[:home_page_path].to_s.strip
98
- if home_page_path_input.present?
99
- normalized_path, error = validate_and_normalize_home_page_path(home_page_path_input)
100
- if error
101
- home_page_setting = SystemSetting.new(key: "home_page_path")
102
- home_page_setting.errors.add(:base, error)
103
- raise ActiveRecord::RecordInvalid, home_page_setting
104
- end
105
- home_page_setting = SystemSetting.find_or_initialize_by(key: "home_page_path")
106
- home_page_setting.value = normalized_path
107
- home_page_setting.save!
108
- else
109
- home_page_setting = SystemSetting.find_or_initialize_by(key: "home_page_path")
110
- home_page_setting.value = nil
111
- home_page_setting.save!
112
- end
97
+ # Home Page Paths (unauthenticated default + authenticated override)
98
+ save_home_page_path("home_page_path", params[:home_page_path])
99
+ save_home_page_path("home_page_path_authenticated", params[:home_page_path_authenticated])
113
100
 
114
101
  # Account Lockout Settings
115
102
  max_attempts = params[:max_login_attempts].to_i
@@ -171,6 +158,7 @@ module Collavre
171
158
  @mcp_tool_approval = params[:mcp_tool_approval] == "1"
172
159
  @creatives_login_required = params[:creatives_login_required] == "1"
173
160
  @home_page_path = params[:home_page_path]
161
+ @home_page_path_authenticated = params[:home_page_path_authenticated]
174
162
  @max_login_attempts = params[:max_login_attempts].to_i.positive? ? params[:max_login_attempts].to_i : SystemSetting::DEFAULT_MAX_LOGIN_ATTEMPTS
175
163
  @lockout_duration_minutes = params[:lockout_duration_minutes].to_i.positive? ? params[:lockout_duration_minutes].to_i : SystemSetting::DEFAULT_LOCKOUT_DURATION_MINUTES
176
164
  @password_min_length = [ [ params[:password_min_length].to_i, SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH ].max, 72 ].min
@@ -186,6 +174,23 @@ module Collavre
186
174
 
187
175
  private
188
176
 
177
+ def save_home_page_path(key, raw_value)
178
+ input = raw_value.to_s.strip
179
+ setting = SystemSetting.find_or_initialize_by(key: key)
180
+ if input.present?
181
+ normalized_path, error = validate_and_normalize_home_page_path(input)
182
+ if error
183
+ stub = SystemSetting.new(key: key)
184
+ stub.errors.add(:base, error)
185
+ raise ActiveRecord::RecordInvalid, stub
186
+ end
187
+ setting.value = normalized_path
188
+ else
189
+ setting.value = nil
190
+ end
191
+ setting.save!
192
+ end
193
+
189
194
  def validate_and_normalize_home_page_path(value)
190
195
  path = value.to_s.strip
191
196