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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeAttachFilesService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
tool_name "creative_attach_files_service"
|
|
11
|
+
tool_description "Attach inline, agent-generated TEXT content (markdown, html, svg, plain text) to a Creative. The content is stored as an ActiveStorage blob, embedded into the Creative's description, and served via CloudFront-cached /public-assets URLs.\n\nUse this for content the agent produced as text (notes, generated markdown/html/svg).\n\nFor BINARY files already on disk (png, mp4, pdf), use the CLI `collavre attach --creative <id> --file <path>` instead — it uploads the raw bytes over a bearer multipart HTTP endpoint without base64 bloat.\n\nRequires :write permission on the target Creative."
|
|
12
|
+
|
|
13
|
+
tool_param :creative_id, description: "ID of the Creative to attach content to.", required: true
|
|
14
|
+
tool_param :files, description: "Array of objects: { filename, content, content_type? }. `content` is the literal UTF-8 text to store (e.g. markdown/html/svg source). `content_type` is inferred from the filename when omitted.", required: true
|
|
15
|
+
|
|
16
|
+
sig { params(creative_id: Integer, files: T::Array[T::Hash[String, T.untyped]]).returns(T::Hash[Symbol, T.untyped]) }
|
|
17
|
+
def call(creative_id:, files:)
|
|
18
|
+
raise "Current.user is required" unless Current.user
|
|
19
|
+
|
|
20
|
+
creative = Creative.find_by(id: creative_id)
|
|
21
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
22
|
+
unless creative.has_permission?(Current.user, :write)
|
|
23
|
+
return { error: "No write permission on Creative", id: creative_id }
|
|
24
|
+
end
|
|
25
|
+
unless creative.attachments_embeddable?
|
|
26
|
+
return { error: "Cannot attach files to GitHub-synced content", id: creative_id }
|
|
27
|
+
end
|
|
28
|
+
return { error: "No files provided" } if files.blank?
|
|
29
|
+
# Validate the entire payload before creating any blob, so a bad entry
|
|
30
|
+
# later in the array never leaves an orphaned blob from an earlier one.
|
|
31
|
+
return { error: "Each file requires a filename" } if files.any? { |f| f["filename"].to_s.blank? }
|
|
32
|
+
|
|
33
|
+
blobs = files.map do |f|
|
|
34
|
+
name = f["filename"].to_s
|
|
35
|
+
content = f["content"].to_s
|
|
36
|
+
|
|
37
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
38
|
+
io: StringIO.new(content),
|
|
39
|
+
filename: name,
|
|
40
|
+
content_type: f["content_type"].presence || Marcel::MimeType.for(name: name) || "text/plain"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
blobs.each { |blob| creative.embed_attachment_blob!(blob) }
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
success: true,
|
|
48
|
+
creative_id: creative.id,
|
|
49
|
+
attachments: blobs.map { |b|
|
|
50
|
+
{
|
|
51
|
+
signed_id: b.signed_id,
|
|
52
|
+
filename: b.filename.to_s,
|
|
53
|
+
content_type: b.content_type,
|
|
54
|
+
byte_size: b.byte_size,
|
|
55
|
+
url: public_asset_url(b)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeListAttachmentsService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
tool_name "creative_list_attachments_service"
|
|
11
|
+
tool_description "List all attachments on a Creative with public URLs, filenames, content types, and sizes. Requires :read permission."
|
|
12
|
+
|
|
13
|
+
tool_param :creative_id, description: "ID of the Creative.", required: true
|
|
14
|
+
|
|
15
|
+
sig { params(creative_id: Integer).returns(T::Hash[Symbol, T.untyped]) }
|
|
16
|
+
def call(creative_id:)
|
|
17
|
+
raise "Current.user is required" unless Current.user
|
|
18
|
+
|
|
19
|
+
creative = Creative.find_by(id: creative_id)
|
|
20
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
21
|
+
|
|
22
|
+
unless creative.has_permission?(Current.user, :read)
|
|
23
|
+
return { error: "No read permission on Creative", id: creative_id }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
success: true,
|
|
28
|
+
creative_id: creative.id,
|
|
29
|
+
attachments: creative.files.with_all_variant_records.map { |a|
|
|
30
|
+
{
|
|
31
|
+
signed_id: a.blob.signed_id,
|
|
32
|
+
filename: a.filename.to_s,
|
|
33
|
+
content_type: a.content_type,
|
|
34
|
+
byte_size: a.byte_size,
|
|
35
|
+
url: public_asset_url(a.blob)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeRemoveAttachmentService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "creative_remove_attachment_service"
|
|
10
|
+
tool_description "Remove a single attachment from a Creative by its signed_id. Requires :write permission. Strips the attachment's node from the description and purges the underlying blob asynchronously when nothing else references it."
|
|
11
|
+
|
|
12
|
+
tool_param :creative_id, description: "ID of the Creative.", required: true
|
|
13
|
+
tool_param :signed_id, description: "signed_id of the blob (from creative_list_attachments_service or creative_attach_files_service response).", required: true
|
|
14
|
+
|
|
15
|
+
sig { params(creative_id: Integer, signed_id: String).returns(T::Hash[Symbol, T.untyped]) }
|
|
16
|
+
def call(creative_id:, signed_id:)
|
|
17
|
+
raise "Current.user is required" unless Current.user
|
|
18
|
+
|
|
19
|
+
creative = Creative.find_by(id: creative_id)
|
|
20
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
21
|
+
|
|
22
|
+
unless creative.has_permission?(Current.user, :write)
|
|
23
|
+
return { error: "No write permission on Creative", id: creative_id }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# HTML is the source of truth: strip the node from the description and let
|
|
27
|
+
# after_save reconcile detach + safe-purge the blob. Removing only the
|
|
28
|
+
# ActiveStorage attachment would leave a dangling node in the description
|
|
29
|
+
# (broken asset, or reconciled back into creative.files on the next save).
|
|
30
|
+
removed = creative.remove_attachment!(signed_id)
|
|
31
|
+
return { error: "Attachment not found on this Creative" } unless removed
|
|
32
|
+
|
|
33
|
+
{ success: true, creative_id: creative.id, removed_signed_id: signed_id }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -5,6 +5,7 @@ module Tools
|
|
|
5
5
|
class CronListService
|
|
6
6
|
extend T::Sig
|
|
7
7
|
extend ToolMeta
|
|
8
|
+
include Collavre::Crons::RecurringTaskArguments
|
|
8
9
|
|
|
9
10
|
tool_name "cron_list"
|
|
10
11
|
tool_description "List recurring scheduled jobs. Returns all cron jobs for creatives the current user has access to. Filter by creative_id to see jobs for a specific creative."
|
|
@@ -46,20 +47,6 @@ module Tools
|
|
|
46
47
|
|
|
47
48
|
{ success: true, cron_jobs: results, count: results.size }
|
|
48
49
|
end
|
|
49
|
-
|
|
50
|
-
private
|
|
51
|
-
|
|
52
|
-
def parse_arguments(task)
|
|
53
|
-
args = task.arguments
|
|
54
|
-
return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
|
|
55
|
-
|
|
56
|
-
args.first.stringify_keys
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def parse_creative_id_from_key(key)
|
|
60
|
-
match = key.match(/\Acron_(\d+)_/)
|
|
61
|
-
match[1].to_i if match
|
|
62
|
-
end
|
|
63
50
|
end
|
|
64
51
|
end
|
|
65
52
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
# Attach a development-preview chip to a topic. AI Agents call this after
|
|
6
|
+
# spinning up `./bin/dev` against a worktree so the preview URL and run
|
|
7
|
+
# state surface as a chip in the typing-indicator row. Idempotent by
|
|
8
|
+
# (topic_id, worktree_id).
|
|
9
|
+
class PreviewAttachService
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend ToolMeta
|
|
12
|
+
|
|
13
|
+
tool_name "preview_attach"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Attach a development preview server to a Collavre topic. Renders a
|
|
16
|
+
chip in the topic's typing-indicator row with a clickable link to the
|
|
17
|
+
preview URL and a state badge (running/stopped). Call this after
|
|
18
|
+
starting a preview server (e.g. ./bin/dev on a worktree). Idempotent
|
|
19
|
+
per (topic_id, worktree_id) — calling twice does not duplicate the
|
|
20
|
+
chip or re-announce.
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
tool_param :topic_id, description: "The Collavre topic id to attach the preview chip to."
|
|
24
|
+
tool_param :preview_url, description: "Full URL of the preview server, e.g. http://localhost:4001"
|
|
25
|
+
tool_param :worktree_id, description: "Stable identifier for the worktree (or environment) running this preview. Used as the dedup key — re-calling with the same worktree_id reactivates the existing chip instead of creating a duplicate."
|
|
26
|
+
tool_param :label, description: "Optional display label shown on the chip. Defaults to a localized 'Preview' string.", required: false
|
|
27
|
+
|
|
28
|
+
sig do
|
|
29
|
+
params(
|
|
30
|
+
topic_id: Integer,
|
|
31
|
+
preview_url: String,
|
|
32
|
+
worktree_id: String,
|
|
33
|
+
label: T.nilable(String)
|
|
34
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
35
|
+
end
|
|
36
|
+
def call(topic_id:, preview_url:, worktree_id:, label: nil)
|
|
37
|
+
validate_preview_url!(preview_url)
|
|
38
|
+
|
|
39
|
+
topic = Collavre::Topic.find(topic_id)
|
|
40
|
+
Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
|
|
41
|
+
|
|
42
|
+
refresh_config = ->(c) {
|
|
43
|
+
# Re-attach against the same worktree may carry a fresh URL/label
|
|
44
|
+
# (e.g. port reassignment after a restart). Refresh the persisted
|
|
45
|
+
# config so the chip link goes to the live server, not the dead one
|
|
46
|
+
# — applies both when reactivating a stopped chip and when the chip
|
|
47
|
+
# is still active (:noop), which is the common dev-restart case.
|
|
48
|
+
new_config = c.config.merge(
|
|
49
|
+
"preview_url" => preview_url,
|
|
50
|
+
"preview_state" => "running"
|
|
51
|
+
)
|
|
52
|
+
new_config["label"] = label if label
|
|
53
|
+
c.config = new_config
|
|
54
|
+
# Also refresh the cached chip fields that record_event! seeded on the
|
|
55
|
+
# first attach. The chip partial renders latest_link.presence ||
|
|
56
|
+
# default_link, so without this update a :noop reattach with a new port
|
|
57
|
+
# leaves the chip pointing at the dead URL even though config is fresh.
|
|
58
|
+
# (On :reactivate the subsequent inject_into_topic! would overwrite
|
|
59
|
+
# these via record_event! anyway, so updating here is harmless.)
|
|
60
|
+
c.latest_link = preview_url
|
|
61
|
+
c.latest_label = c.default_label
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
channel, status = Collavre::ChannelAttacher.call(
|
|
65
|
+
channel_class: Collavre::PreviewChannel,
|
|
66
|
+
lookup: -> { lookup_channel(topic, worktree_id) },
|
|
67
|
+
create_attrs: {
|
|
68
|
+
topic_id: topic.id,
|
|
69
|
+
config: {
|
|
70
|
+
"worktree_id" => worktree_id,
|
|
71
|
+
"preview_url" => preview_url,
|
|
72
|
+
"preview_state" => "running",
|
|
73
|
+
"label" => label
|
|
74
|
+
}.compact
|
|
75
|
+
},
|
|
76
|
+
on_reactivate: refresh_config,
|
|
77
|
+
on_noop: refresh_config
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Mirror PR-channel UX: only the first attach (and a true reactivation
|
|
81
|
+
# from stopped/dismissed) announces in the topic. Subsequent idempotent
|
|
82
|
+
# attaches stay silent so frequent restarts do not spam the timeline.
|
|
83
|
+
if status == :created || status == :reactivated
|
|
84
|
+
channel.inject_into_topic!(channel.attached_message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
ok: true,
|
|
89
|
+
channel_id: channel.id,
|
|
90
|
+
worktree_id: worktree_id,
|
|
91
|
+
preview_url: preview_url,
|
|
92
|
+
status: status
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
ALLOWED_PREVIEW_URL_SCHEMES = %w[http https].freeze
|
|
99
|
+
private_constant :ALLOWED_PREVIEW_URL_SCHEMES
|
|
100
|
+
|
|
101
|
+
# The preview_url is rendered directly as the chip's <a href> via ERB,
|
|
102
|
+
# which escapes quotes but does NOT reject dangerous URL schemes. Without
|
|
103
|
+
# this gate a write-capable MCP caller could persist `javascript:...` or
|
|
104
|
+
# `data:...` and turn the chip into a one-click XSS for any topic viewer.
|
|
105
|
+
sig { params(preview_url: String).void }
|
|
106
|
+
def validate_preview_url!(preview_url)
|
|
107
|
+
uri = URI.parse(preview_url)
|
|
108
|
+
unless ALLOWED_PREVIEW_URL_SCHEMES.include?(uri.scheme&.downcase)
|
|
109
|
+
raise ArgumentError, "preview_url must be http(s); got: #{preview_url.inspect}"
|
|
110
|
+
end
|
|
111
|
+
raise ArgumentError, "preview_url must include a host" if uri.host.to_s.empty?
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# config is JSON, so worktree_id matching is a Ruby-side scan over the
|
|
117
|
+
# topic's preview channels. With only a handful of previews per topic in
|
|
118
|
+
# practice this is fine and avoids JSON operator divergence between
|
|
119
|
+
# Postgres (config->>'worktree_id') and SQLite (json_extract).
|
|
120
|
+
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
121
|
+
def lookup_channel(topic, worktree_id)
|
|
122
|
+
Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
|
|
123
|
+
c.worktree_id.to_s == worktree_id.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
# Mark a previously-attached preview as stopped. AI Agents call this
|
|
6
|
+
# immediately before killing the preview server (per the worktree cleanup
|
|
7
|
+
# workflow) so the chip flips to the "stopped" badge with reduced opacity.
|
|
8
|
+
# The chip stays visible until the user dismisses it with the X button —
|
|
9
|
+
# mirrors the PR-channel post-close UX where the closed badge persists for
|
|
10
|
+
# context.
|
|
11
|
+
class PreviewDetachService
|
|
12
|
+
extend T::Sig
|
|
13
|
+
extend ToolMeta
|
|
14
|
+
|
|
15
|
+
tool_name "preview_detach"
|
|
16
|
+
tool_description <<~DESC.strip
|
|
17
|
+
Mark a development preview as stopped. The chip stays visible with a
|
|
18
|
+
stopped badge until the user dismisses it. Idempotent — calling on an
|
|
19
|
+
already-stopped or missing channel returns ok with status :noop.
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
tool_param :topic_id, description: "The Collavre topic id the preview was attached to."
|
|
23
|
+
tool_param :worktree_id, description: "The worktree_id used when the preview was attached."
|
|
24
|
+
|
|
25
|
+
sig do
|
|
26
|
+
params(
|
|
27
|
+
topic_id: Integer,
|
|
28
|
+
worktree_id: String
|
|
29
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
30
|
+
end
|
|
31
|
+
def call(topic_id:, worktree_id:)
|
|
32
|
+
topic = Collavre::Topic.find(topic_id)
|
|
33
|
+
Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
|
|
34
|
+
|
|
35
|
+
channel = lookup_channel(topic, worktree_id)
|
|
36
|
+
return { ok: true, status: :noop, worktree_id: worktree_id } if channel.nil?
|
|
37
|
+
|
|
38
|
+
status =
|
|
39
|
+
if channel.preview_state == "stopped" && channel.detached?
|
|
40
|
+
:noop
|
|
41
|
+
else
|
|
42
|
+
channel.preview_state = "stopped"
|
|
43
|
+
channel.state = :detached unless channel.detached?
|
|
44
|
+
channel.save!
|
|
45
|
+
:stopped
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ ok: true, channel_id: channel.id, worktree_id: worktree_id, status: status }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
54
|
+
def lookup_channel(topic, worktree_id)
|
|
55
|
+
Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
|
|
56
|
+
c.worktree_id.to_s == worktree_id.to_s
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Tools
|
|
5
|
+
# Topic write authorization for MCP tool services. Mirrors
|
|
6
|
+
# CreativePermissionGuard#require_creative_write! but is callable outside a
|
|
7
|
+
# controller request. Attaching a channel injects external messages into a
|
|
8
|
+
# topic, so it is a write-equivalent mutation — restrict to users with
|
|
9
|
+
# write permission on the topic's effective_origin creative.
|
|
10
|
+
module TopicAuthorizer
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def authorize_write!(topic, user: Collavre::Current.user)
|
|
14
|
+
creative = topic.creative&.effective_origin
|
|
15
|
+
raise ArgumentError, "Topic has no creative" unless creative
|
|
16
|
+
return if creative.user == user
|
|
17
|
+
return if user && creative.has_permission?(user, :write)
|
|
18
|
+
|
|
19
|
+
raise Collavre::Tools::PermissionDeniedError,
|
|
20
|
+
"No write permission on topic #{topic.id}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -4,16 +4,23 @@ module Collavre
|
|
|
4
4
|
|
|
5
5
|
MAX_BRANCH_COMMENTS = 100
|
|
6
6
|
|
|
7
|
-
def initialize(creative:, user:, source_topic:)
|
|
7
|
+
def initialize(creative:, user:, source_topic:, name: nil)
|
|
8
8
|
@creative = creative
|
|
9
9
|
@user = user
|
|
10
10
|
@source_topic = source_topic
|
|
11
|
+
@name = name
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
# Creates a new topic with copies of the selected comments.
|
|
14
15
|
# Returns the new Topic.
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
# enforce_limit: false bypasses MAX_BRANCH_COMMENTS for system-initiated
|
|
17
|
+
# full-history copies (e.g. Drop Trigger) where the UI's selection cap
|
|
18
|
+
# does not apply.
|
|
19
|
+
# auto_select: false omits user_id from the topic-created broadcast so
|
|
20
|
+
# background/system branches do not hijack the owner's current selection.
|
|
21
|
+
def call(comment_ids:, enforce_limit: true, auto_select: true)
|
|
22
|
+
comment_ids = Array(comment_ids).map(&:presence).compact.map(&:to_i)
|
|
23
|
+
comment_ids = comment_ids.first(MAX_BRANCH_COMMENTS) if enforce_limit
|
|
17
24
|
raise BranchError, I18n.t("collavre.comments.branch.no_selection") if comment_ids.empty?
|
|
18
25
|
|
|
19
26
|
validate_permissions!
|
|
@@ -24,14 +31,14 @@ module Collavre
|
|
|
24
31
|
copy_comments(originals)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
|
-
broadcast_topic_created
|
|
34
|
+
broadcast_topic_created(auto_select: auto_select)
|
|
28
35
|
|
|
29
36
|
@new_topic
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
private
|
|
33
40
|
|
|
34
|
-
attr_reader :creative, :user, :source_topic
|
|
41
|
+
attr_reader :creative, :user, :source_topic, :name
|
|
35
42
|
|
|
36
43
|
def validate_permissions!
|
|
37
44
|
unless creative.has_permission?(user, :feedback)
|
|
@@ -49,25 +56,28 @@ module Collavre
|
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
def create_branch_topic
|
|
52
|
-
|
|
53
|
-
source_name = source_topic&.name || I18n.t("collavre.comments.topic_main", default: "All Messages")
|
|
54
|
-
name = "#{prefix}:#{source_name}"
|
|
55
|
-
|
|
56
|
-
# Ensure uniqueness
|
|
57
|
-
existing = creative.topics.where("name LIKE ?", "#{Topic.sanitize_sql_like(name)}%").pluck(:name)
|
|
58
|
-
if existing.include?(name)
|
|
59
|
-
counter = 2
|
|
60
|
-
counter += 1 while existing.include?("#{name} #{counter}")
|
|
61
|
-
name = "#{name} #{counter}"
|
|
62
|
-
end
|
|
59
|
+
topic_name = name.presence || default_branch_name
|
|
63
60
|
|
|
64
61
|
creative.topics.create!(
|
|
65
|
-
name:
|
|
62
|
+
name: topic_name,
|
|
66
63
|
user: user,
|
|
67
64
|
source_topic_id: source_topic&.id
|
|
68
65
|
)
|
|
69
66
|
end
|
|
70
67
|
|
|
68
|
+
def default_branch_name
|
|
69
|
+
prefix = I18n.t("collavre.topics.branch_prefix")
|
|
70
|
+
source_name = source_topic&.name || I18n.t("collavre.comments.topic_main", default: "All Messages")
|
|
71
|
+
candidate = "#{prefix}:#{source_name}"
|
|
72
|
+
|
|
73
|
+
existing = creative.topics.where("name LIKE ?", "#{Topic.sanitize_sql_like(candidate)}%").pluck(:name)
|
|
74
|
+
return candidate unless existing.include?(candidate)
|
|
75
|
+
|
|
76
|
+
counter = 2
|
|
77
|
+
counter += 1 while existing.include?("#{candidate} #{counter}")
|
|
78
|
+
"#{candidate} #{counter}"
|
|
79
|
+
end
|
|
80
|
+
|
|
71
81
|
def copy_comments(originals)
|
|
72
82
|
id_mapping = {}
|
|
73
83
|
|
|
@@ -98,15 +108,13 @@ module Collavre
|
|
|
98
108
|
end
|
|
99
109
|
end
|
|
100
110
|
|
|
101
|
-
def broadcast_topic_created
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
)
|
|
111
|
+
def broadcast_topic_created(auto_select: true)
|
|
112
|
+
payload = {
|
|
113
|
+
action: "created",
|
|
114
|
+
topic: { id: @new_topic.id, name: @new_topic.name, source_topic_id: @new_topic.source_topic_id }
|
|
115
|
+
}
|
|
116
|
+
payload[:user_id] = user.id if auto_select
|
|
117
|
+
TopicsChannel.broadcast_to(creative, payload)
|
|
110
118
|
end
|
|
111
119
|
end
|
|
112
120
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Topics
|
|
5
|
+
# When a Topic is deleted, find every non-static recurring cron task that
|
|
6
|
+
# targets that topic (topic_id stored inside the task's arguments JSON) and
|
|
7
|
+
# post a system message into the affected creative's Main topic so the user
|
|
8
|
+
# knows the cron is now orphaned.
|
|
9
|
+
#
|
|
10
|
+
# Decision: notify only. The recurring task is intentionally left in place
|
|
11
|
+
# so the user can decide whether to re-point or cancel it.
|
|
12
|
+
class OrphanedCronNotifier
|
|
13
|
+
include Collavre::Crons::RecurringTaskArguments
|
|
14
|
+
|
|
15
|
+
def initialize(topic_id:, topic_name:)
|
|
16
|
+
@topic_id = topic_id
|
|
17
|
+
@topic_name = topic_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return if @topic_id.blank?
|
|
22
|
+
|
|
23
|
+
matching_tasks.each do |task, args|
|
|
24
|
+
notify_for(task, args)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def matching_tasks
|
|
31
|
+
SolidQueue::RecurringTask.where(static: false).filter_map do |task|
|
|
32
|
+
args = parse_arguments(task)
|
|
33
|
+
target = args["topic_id"]
|
|
34
|
+
next if target.blank?
|
|
35
|
+
next unless target.to_i == @topic_id.to_i
|
|
36
|
+
|
|
37
|
+
[ task, args ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def notify_for(task, args)
|
|
42
|
+
creative_id = args["creative_id"] || parse_creative_id_from_key(task.key)
|
|
43
|
+
creative = Creative.find_by(id: creative_id)
|
|
44
|
+
return unless creative
|
|
45
|
+
|
|
46
|
+
# The recurring task stores the original (possibly linked/child) creative
|
|
47
|
+
# id, but Comment#use_origin_creative rewrites the comment to the origin.
|
|
48
|
+
# Post to the origin's Main topic so the notice stays on the origin and
|
|
49
|
+
# is reachable from normal topic navigation (matches CronCreateService).
|
|
50
|
+
main_topic = creative.effective_origin.main_topic
|
|
51
|
+
return unless main_topic
|
|
52
|
+
|
|
53
|
+
Comment.create!(
|
|
54
|
+
creative: creative,
|
|
55
|
+
topic_id: main_topic.id,
|
|
56
|
+
user: nil, # System message
|
|
57
|
+
skip_default_user: true, # keep user nil even when a deleter is Current.user; don't trigger AI orchestration
|
|
58
|
+
content: I18n.t(
|
|
59
|
+
"collavre.topics.orphaned_cron_notice",
|
|
60
|
+
topic_name: @topic_name,
|
|
61
|
+
cron_key: task.key,
|
|
62
|
+
message: args["message"]
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
<%= link_to t('admin.tabs.uiux'), collavre.admin_uiux_path, class: "tab-button #{'active' if controller_name == 'settings' && action_name == 'uiux'}" %>
|
|
4
4
|
<%= link_to t('admin.tabs.users'), collavre.users_path, class: "tab-button #{'active' if controller_name == 'users'}" %>
|
|
5
5
|
<%= link_to t('admin.tabs.orchestration'), collavre.admin_orchestration_path, class: "tab-button #{'active' if controller_name == 'orchestration'}" %>
|
|
6
|
+
<%= link_to t('collavre.admin.integrations.tab_label'), collavre.admin_integrations_path, class: "tab-button #{'active' if controller_name == 'integrations'}" %>
|
|
6
7
|
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<div style="margin-top: 2em; border-top: 1px solid var(--color-border); padding-top: 1.5em;">
|
|
2
|
+
<h3 style="font-size: 1.1em; margin-bottom: 1em;">
|
|
3
|
+
<%= t("collavre.admin.integrations.category.#{category}", default: category.to_s.humanize) %>
|
|
4
|
+
</h3>
|
|
5
|
+
|
|
6
|
+
<div class="table-scroll">
|
|
7
|
+
<table class="settings-table" style="width: 100%; min-width: 640px; border-collapse: collapse; table-layout: fixed;">
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th style="text-align: left; padding: 0.5em; width: 30%;"><%= t("collavre.admin.integrations.headers.key") %></th>
|
|
11
|
+
<th style="text-align: left; padding: 0.5em; width: 50%;"><%= t("collavre.admin.integrations.headers.value") %></th>
|
|
12
|
+
<th style="text-align: left; padding: 0.5em; width: 20%;"><%= t("collavre.admin.integrations.headers.actions") %></th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody>
|
|
16
|
+
<% rows.each do |row| %>
|
|
17
|
+
<%= render partial: "setting_row", locals: { row: row, bulk_form_id: bulk_form_id } %>
|
|
18
|
+
<% end %>
|
|
19
|
+
</tbody>
|
|
20
|
+
</table>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<% definition = row[:definition] %>
|
|
2
|
+
<% key_str = definition.key.to_s %>
|
|
3
|
+
<%
|
|
4
|
+
source_bg, source_fg = case row[:source]
|
|
5
|
+
when :db then ["var(--color-success)", "var(--text-on-badge)"]
|
|
6
|
+
when :env then ["var(--color-warning)", "var(--text-on-badge)"]
|
|
7
|
+
else ["var(--surface-btn)", "var(--text-on-btn)"]
|
|
8
|
+
end
|
|
9
|
+
current_display = row[:present] ? row[:display_value].to_s : "—"
|
|
10
|
+
%>
|
|
11
|
+
<% description = t("collavre.admin.integrations.descriptions.#{key_str}", default: "") %>
|
|
12
|
+
<tr style="border-top: 1px solid var(--color-border);">
|
|
13
|
+
<td style="padding: 0.5em; vertical-align: top; word-break: break-word; overflow-wrap: anywhere;">
|
|
14
|
+
<code style="word-break: break-all; overflow-wrap: anywhere;"><%= key_str %></code>
|
|
15
|
+
<% if definition.requires_restart %>
|
|
16
|
+
<span class="badge badge-warning" style="display: inline-block; margin-left: 0.5em; padding: 0.1em 0.5em; font-size: 0.75em; background: var(--color-warning); color: var(--text-on-badge); border-radius: 3px;">
|
|
17
|
+
<%= t("collavre.admin.integrations.restart_required") %>
|
|
18
|
+
</span>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if description.present? %>
|
|
21
|
+
<div style="font-size: 0.85em; color: var(--color-text); margin-top: 0.4em; line-height: 1.4;">
|
|
22
|
+
<%= simple_format(description) %>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
<div style="font-size: 0.8em; color: var(--color-muted); margin-top: 0.25em;">
|
|
26
|
+
<%= t("collavre.admin.integrations.env_var_label") %>: <code style="word-break: break-all; overflow-wrap: anywhere;"><%= definition.env_var %></code>
|
|
27
|
+
</div>
|
|
28
|
+
</td>
|
|
29
|
+
<td style="padding: 0.5em; vertical-align: top;">
|
|
30
|
+
<%# Input references the bulk form via HTML5 `form` attribute so the table is not nested inside <form>. %>
|
|
31
|
+
<%# Placeholder displays the current value (masked for sensitive); empty input keeps the existing value. %>
|
|
32
|
+
<% if definition.input_type == :textarea %>
|
|
33
|
+
<%= text_area_tag "integration_setting[#{key_str}]",
|
|
34
|
+
nil,
|
|
35
|
+
form: bulk_form_id,
|
|
36
|
+
placeholder: current_display,
|
|
37
|
+
autocomplete: "off",
|
|
38
|
+
rows: 8,
|
|
39
|
+
style: "width: 100%; font-family: var(--font-mono, monospace); font-size: 0.85em; min-height: 8em;" %>
|
|
40
|
+
<% else %>
|
|
41
|
+
<% input_tag = definition.sensitive ? :password_field_tag : :text_field_tag %>
|
|
42
|
+
<%= send(input_tag,
|
|
43
|
+
"integration_setting[#{key_str}]",
|
|
44
|
+
nil,
|
|
45
|
+
form: bulk_form_id,
|
|
46
|
+
placeholder: current_display,
|
|
47
|
+
autocomplete: "off",
|
|
48
|
+
style: "width: 100%;") %>
|
|
49
|
+
<% end %>
|
|
50
|
+
<div style="font-size: 0.8em; margin-top: 0.25em;">
|
|
51
|
+
<span class="badge badge-source-<%= row[:source] %>" style="display: inline-block; padding: 0.1em 0.5em; font-size: 0.85em; border-radius: 3px; background: <%= source_bg %>; color: <%= source_fg %>;">
|
|
52
|
+
<%= t("collavre.admin.integrations.source.#{row[:source]}", default: row[:source].to_s.upcase) %>
|
|
53
|
+
</span>
|
|
54
|
+
<% if definition.sensitive %>
|
|
55
|
+
<span style="margin-left: 0.5em; color: var(--color-muted);"><%= t("collavre.admin.integrations.sensitive") %></span>
|
|
56
|
+
<% end %>
|
|
57
|
+
<span style="margin-left: 0.5em; color: var(--color-muted);"><%= t("collavre.admin.integrations.placeholder_leave_blank") %></span>
|
|
58
|
+
</div>
|
|
59
|
+
</td>
|
|
60
|
+
<td style="padding: 0.5em; vertical-align: top; white-space: nowrap;">
|
|
61
|
+
<%= button_to t("collavre.admin.integrations.actions.reset"),
|
|
62
|
+
collavre.reset_admin_integration_path(key: key_str),
|
|
63
|
+
method: :delete,
|
|
64
|
+
form: {
|
|
65
|
+
style: "display: inline-block;",
|
|
66
|
+
data: { turbo_confirm: t("collavre.admin.integrations.confirm_reset") }
|
|
67
|
+
},
|
|
68
|
+
class: "btn btn-small btn-secondary" %>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|