command_tower 0.16.0 → 0.18.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/application_controller.rb +1 -0
  4. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  5. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  6. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  9. data/app/models/command_tower/messaging/communication.rb +31 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  13. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  14. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  15. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  16. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  17. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  18. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  19. data/app/services/command_tower/messaging/inbox.rb +4 -0
  20. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  21. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  22. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  23. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
  24. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  25. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  26. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  27. data/app/services/command_tower/messaging.rb +5 -1
  28. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  29. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  30. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  31. data/app/services/command_tower/services/messaging/inbox.rb +55 -2
  32. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  33. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  34. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  35. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  36. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  37. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  38. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  39. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  40. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  41. data/config/routes.rb +1 -0
  42. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  43. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  44. data/docs/api_reference.md +13 -2
  45. data/docs/authorization.md +1 -1
  46. data/docs/bootstrap/00-ownership.md +77 -0
  47. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  48. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  49. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  50. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  51. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  52. data/docs/bootstrap/06-mount-and-health.md +53 -0
  53. data/docs/bootstrap/07-execution-bases.md +38 -0
  54. data/docs/bootstrap/08-initializer.md +90 -0
  55. data/docs/bootstrap/09-rbac.md +83 -0
  56. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  57. data/docs/bootstrap/11-auth-client-path.md +57 -0
  58. data/docs/bootstrap/12-smoke-check.md +81 -0
  59. data/docs/bootstrap/13-optional.md +50 -0
  60. data/docs/bootstrap/14-sanity-checks.md +44 -0
  61. data/docs/bootstrap/README.md +90 -0
  62. data/docs/cookie_authentication_guide.md +11 -0
  63. data/docs/extending.md +5 -4
  64. data/docs/host_integration_guide.md +3 -246
  65. data/docs/initializing.md +37 -28
  66. data/docs/messaging_integration_guide.md +39 -1
  67. data/docs/principal_capabilities.md +1 -1
  68. data/docs/upgrades/0.10.0.md +5 -5
  69. data/docs/upgrades/0.11.0.md +5 -5
  70. data/docs/upgrades/0.17.0.md +33 -0
  71. data/docs/upgrades/0.18.0.md +35 -0
  72. data/docs/upgrades/README.md +3 -1
  73. data/lib/command_tower/authorization/default.yml +1 -0
  74. data/lib/command_tower/client_compatibility/version.rb +45 -0
  75. data/lib/command_tower/client_compatibility.rb +23 -0
  76. data/lib/command_tower/configuration/config.rb +6 -0
  77. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  78. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  79. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  80. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  81. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  82. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  83. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  84. data/lib/command_tower/configuration/registry/config.rb +14 -0
  85. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  86. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  87. data/lib/command_tower/current.rb +1 -0
  88. data/lib/command_tower/engine.rb +4 -0
  89. data/lib/command_tower/inbox_presentations.rb +19 -0
  90. data/lib/command_tower/install/baseline.rb +2 -0
  91. data/lib/command_tower/version.rb +1 -1
  92. metadata +47 -2
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module ClientCompatibility
6
+ # Pure, deterministic client-version-compatibility decision.
7
+ #
8
+ # Same (headers, controller/action, registry state) always produces the
9
+ # same complete `Decision`. This service MUST NOT mutate
10
+ # `CommandTower::Current` or any other request-global — it has zero side
11
+ # effects beyond returning its result. Any placement of recommendation
12
+ # metadata onto `Current` is transport plumbing that belongs to the
13
+ # workflow or boundary layer, never here.
14
+ class Evaluate
15
+ DECISIONS = %i[compatible recommended incompatible identity_invalid].freeze
16
+
17
+ # Immutable decision/projection. `recommendation_projection` is the
18
+ # camelCase `clientCompatibility` shape (authority §13), present only
19
+ # when identity is parseable and current >= effective minimum.
20
+ class Decision
21
+ attr_reader :decision, :platform, :current_version, :effective_minimum, :scope,
22
+ :recovery, :update_url, :matched_entity_names, :recommendation_projection
23
+
24
+ def initialize(
25
+ decision:,
26
+ platform: nil,
27
+ current_version: nil,
28
+ effective_minimum: nil,
29
+ scope: nil,
30
+ recovery: nil,
31
+ update_url: nil,
32
+ matched_entity_names: [],
33
+ recommendation_projection: nil
34
+ )
35
+ @decision = decision
36
+ @platform = platform
37
+ @current_version = current_version
38
+ @effective_minimum = effective_minimum
39
+ @scope = scope
40
+ @recovery = recovery
41
+ @update_url = update_url
42
+ @matched_entity_names = matched_entity_names
43
+ @recommendation_projection = recommendation_projection
44
+ end
45
+
46
+ def compatible? = %i[compatible recommended].include?(@decision)
47
+
48
+ def incompatible? = !compatible?
49
+ end
50
+
51
+ def self.call(app_version_header:, platform_header:, controller_class:, action_name:, registry: CommandTower.config.registry.client_compatibility)
52
+ new(
53
+ app_version_header:,
54
+ platform_header:,
55
+ controller_class:,
56
+ action_name:,
57
+ registry:
58
+ ).call
59
+ end
60
+
61
+ def initialize(app_version_header:, platform_header:, controller_class:, action_name:, registry:)
62
+ @app_version_header = app_version_header
63
+ @platform_header = platform_header
64
+ @controller_class = controller_class
65
+ @action_name = action_name.to_s
66
+ @registry = registry
67
+ end
68
+
69
+ def call
70
+ return no_op_decision unless registry.opted_in?
71
+
72
+ platform = normalized_platform
73
+ version = normalized_version
74
+ return identity_invalid_decision(platform:) if platform.nil? || version.nil?
75
+
76
+ policy = registry.platform_policy(platform)
77
+ return identity_invalid_decision(platform:, current_version: version) if policy.nil?
78
+
79
+ matched = matched_entity_names
80
+ effective_minimum, scope = effective_minimum_and_scope(policy:, platform:, matched:)
81
+
82
+ current = Gem::Version.new(version)
83
+ minimum = Gem::Version.new(effective_minimum)
84
+
85
+ if current < minimum
86
+ return incompatible_decision(
87
+ platform:, current_version: version, effective_minimum:, scope:, matched:, policy:
88
+ )
89
+ end
90
+
91
+ compatible_or_recommended_decision(
92
+ platform:, current_version: version, effective_minimum:, matched:, policy:, current:
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :controller_class, :action_name, :registry
99
+
100
+ def normalized_platform
101
+ token = @platform_header.to_s.strip.downcase
102
+ return nil if token.empty?
103
+ return nil unless CommandTower::Configuration::Registry::ClientCompatibility::Config::PLATFORMS.map(&:to_s).include?(token)
104
+
105
+ token.to_sym
106
+ end
107
+
108
+ def normalized_version
109
+ CommandTower::ClientCompatibility::Version.normalize(@app_version_header)
110
+ end
111
+
112
+ def matched_entity_names
113
+ CommandTower::Authorization::Entity.entities.values
114
+ .select { |entity| entity.matches?(controller: controller_class, method: action_name.to_sym) == true }
115
+ .map { |entity| entity.name.to_s }
116
+ end
117
+
118
+ def effective_minimum_and_scope(policy:, platform:, matched:)
119
+ strictest = policy.minimum
120
+ scope = :application
121
+
122
+ matched.each do |entity_name|
123
+ requirement = registry.entity_requirement(entity_name)
124
+ next if requirement.nil?
125
+
126
+ entity_minimum = entity_minimum_for(requirement, platform:)
127
+ next if entity_minimum.nil?
128
+
129
+ if Gem::Version.new(entity_minimum) > Gem::Version.new(strictest)
130
+ strictest = entity_minimum
131
+ scope = :capability
132
+ end
133
+ end
134
+
135
+ [strictest, scope]
136
+ end
137
+
138
+ def entity_minimum_for(requirement, platform:)
139
+ if requirement.client_contract.present?
140
+ binding = registry.binding_for(requirement.client_contract)
141
+ binding&.for_platform(platform)
142
+ else
143
+ requirement.minimum.for_platform(platform)
144
+ end
145
+ end
146
+
147
+ def no_op_decision
148
+ Decision.new(decision: :compatible)
149
+ end
150
+
151
+ def identity_invalid_decision(platform: nil, current_version: nil)
152
+ Decision.new(
153
+ decision: :identity_invalid,
154
+ platform:,
155
+ current_version:,
156
+ scope: :application,
157
+ recovery: recovery_for(platform),
158
+ update_url: update_url_for(platform)
159
+ )
160
+ end
161
+
162
+ def incompatible_decision(platform:, current_version:, effective_minimum:, scope:, matched:, policy:)
163
+ Decision.new(
164
+ decision: :incompatible,
165
+ platform:,
166
+ current_version:,
167
+ effective_minimum:,
168
+ scope:,
169
+ recovery: recovery_for(platform),
170
+ update_url: update_url_for(platform),
171
+ matched_entity_names: matched,
172
+ recommendation_projection: nil
173
+ )
174
+ end
175
+
176
+ def compatible_or_recommended_decision(platform:, current_version:, effective_minimum:, matched:, policy:, current:)
177
+ recommended = policy.recommended
178
+ update_available = recommended.present? && current < Gem::Version.new(recommended)
179
+
180
+ Decision.new(
181
+ decision: update_available ? :recommended : :compatible,
182
+ platform:,
183
+ current_version:,
184
+ effective_minimum:,
185
+ scope: :application,
186
+ matched_entity_names: matched,
187
+ recommendation_projection: recommendation_projection(
188
+ platform:, current_version:, effective_minimum:, recommended:, update_available:
189
+ )
190
+ )
191
+ end
192
+
193
+ def recommendation_projection(platform:, current_version:, effective_minimum:, recommended:, update_available:)
194
+ {
195
+ platform: platform.to_s,
196
+ currentVersion: current_version,
197
+ minimumVersion: effective_minimum,
198
+ recommendedVersion: recommended,
199
+ updateAvailable: update_available,
200
+ recovery: recovery_for(platform).to_s,
201
+ updateUrl: update_url_for(platform)
202
+ }.compact
203
+ end
204
+
205
+ def recovery_for(platform)
206
+ return :reload if platform.nil? || platform == :web
207
+
208
+ :app_store
209
+ end
210
+
211
+ def update_url_for(platform)
212
+ return nil if platform.nil? || platform == :web
213
+
214
+ registry.platform_policy(platform)&.update_url
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -15,6 +15,8 @@ module CommandTower
15
15
  validate :body, is_a: String, required: true
16
16
  validate :metadata, is_a: Hash, required: false
17
17
  validate :platform_enabled_channels, is_a: Array, required: true
18
+ validate :conversation_key, is_a: String, required: false
19
+ validate :conversation_title, is_a: String, required: false
18
20
 
19
21
  def call
20
22
  recipient_result = CommandTower::Services::Messaging::Recipients.call(user: user)
@@ -33,6 +35,8 @@ module CommandTower
33
35
  preference_state: nil,
34
36
  platform_enabled_channels:,
35
37
  message_overrides: nil,
38
+ conversation_key:,
39
+ conversation_title:,
36
40
  )
37
41
 
38
42
  # Use communication_status — ServiceResult strips Interactor reserved :status.
@@ -18,6 +18,8 @@ module CommandTower
18
18
  validate :body, is_a: String, required: true
19
19
  validate :platform_enabled_channels, is_a: Array, required: true
20
20
  validate :metadata, is_a: Hash, required: false
21
+ validate :conversation_key, is_a: String, required: false
22
+ validate :conversation_title, is_a: String, required: false
21
23
  validate :execution_mode, is_one: EXECUTION_MODES, required: false, default: :async
22
24
 
23
25
  def call
@@ -128,6 +130,8 @@ module CommandTower
128
130
  body:,
129
131
  metadata:,
130
132
  platform_enabled_channels:,
133
+ conversation_key:,
134
+ conversation_title:,
131
135
  )
132
136
  end
133
137
 
@@ -139,6 +143,8 @@ module CommandTower
139
143
  "body" => body,
140
144
  "platform_enabled_channels" => platform_enabled_channels,
141
145
  "metadata" => metadata,
146
+ "conversation_key" => conversation_key,
147
+ "conversation_title" => conversation_title,
142
148
  }
143
149
  end
144
150
 
@@ -34,7 +34,7 @@ module CommandTower
34
34
  def call
35
35
  with_recipient do |recipient_id|
36
36
  result = CommandTower::Messaging::Inbox.list(recipient_id:, limit:, offset:, scope:)
37
- context.items = result.items.map { |item| Inbox.item(item) }
37
+ context.items = result.items.map { |entry| Inbox.entry(entry) }
38
38
  context.pagination = { limit: result.limit, offset: result.offset, total_count: result.total_count }
39
39
  end
40
40
  end
@@ -80,6 +80,37 @@ module CommandTower
80
80
  end
81
81
  end
82
82
 
83
+ class Conversation < CommandTower::Services::ApplicationService
84
+ include FailureMapping
85
+ validate :user, is_a: User, required: true
86
+ validate :key, is_a: String, required: true
87
+ validate :scope, is_a: String, required: false, default: "inbox"
88
+ validate :around, is_a: [String, Integer], required: false
89
+ validate :before_id, is_a: Integer, required: false
90
+ validate :after_id, is_a: Integer, required: false
91
+ validate :limit, is_a: Integer, required: false
92
+
93
+ def call
94
+ with_recipient do |recipient_id|
95
+ found = CommandTower::Messaging::Inbox.conversation(
96
+ recipient_id:, key:, scope:, around:, before_id:, after_id:, limit:,
97
+ )
98
+ messages = found.message_ids.map do |inbox_item_id|
99
+ Inbox.detail(recipient_id:, inbox_item_id:)
100
+ end
101
+ context.conversation = {
102
+ scope: found.scope,
103
+ subject: found.subject,
104
+ message_count: found.message_count,
105
+ unread_count: found.unread_count,
106
+ inbox_item_ids: found.inbox_item_ids,
107
+ messages:,
108
+ window: found.window,
109
+ }
110
+ end
111
+ end
112
+ end
113
+
83
114
  class UnreadCount < CommandTower::Services::ApplicationService
84
115
  include FailureMapping
85
116
  validate :user, is_a: User, required: true
@@ -115,12 +146,34 @@ module CommandTower
115
146
  { id: item.id, title: item.title, status: item.status, viewed_at: item.viewed_at, created_at: item.created_at, updated_at: item.updated_at }
116
147
  end
117
148
 
149
+ def entry(entry)
150
+ {
151
+ kind: entry.kind,
152
+ id: entry.id,
153
+ title: entry.title,
154
+ preview: entry.preview,
155
+ activity_at: entry.activity_at,
156
+ created_at: entry.created_at,
157
+ message_count: entry.message_count,
158
+ unread_count: entry.unread_count,
159
+ read: entry.read,
160
+ inbox_item_ids: entry.inbox_item_ids,
161
+ }
162
+ end
163
+
118
164
  def detail(recipient_id:, inbox_item_id:)
119
165
  item = CommandTower::Messaging::Inbox.show(recipient_id:, inbox_item_id:)
120
166
  communication = CommandTower::Messaging::Contract::Communications.find(
121
167
  CommandTower::Messaging::Contract::Requests::FindCommunication.build(communication_id: item.communication_id, recipient_id:)
122
168
  )
123
- item(item).merge(body: communication.body, metadata: communication.metadata, notification_type_key: communication.notification_type_key)
169
+ content = CommandTower::Messaging::Rendering::InboxDocumentRenderer.render(communication:, recipient_id:).to_h
170
+
171
+ item(item).merge(
172
+ body: communication.body,
173
+ metadata: communication.metadata,
174
+ notification_type_key: communication.notification_type_key,
175
+ content:,
176
+ )
124
177
  end
125
178
  end
126
179
  end
@@ -1,11 +1,12 @@
1
- <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f7fa; padding: 40px 20px;">
1
+ <% theme = CommandTower::EmailTheme::Resolver.resolve -%>
2
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 40px 20px;">
2
3
  <tr>
3
4
  <td align="center">
4
- <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
5
+ <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
5
6
  <!-- Header -->
6
7
  <tr>
7
- <td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
8
- <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
8
+ <td style="background: <%= theme[:accent] %>; padding: 40px 30px; text-align: center;">
9
+ <h1 style="margin: 0; color: <%= theme[:text_on_accent] %>; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
9
10
  Welcome to <%= CommandTower.config.app.communication_name %>!
10
11
  </h1>
11
12
  </td>
@@ -14,44 +15,44 @@
14
15
  <!-- Content -->
15
16
  <tr>
16
17
  <td style="padding: 40px 30px;">
17
- <p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
18
- Hello <strong style="color: #2d3748;"><%= @user.full_name %></strong>,
18
+ <p style="margin: 0 0 20px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
19
+ Hello <strong style="color: <%= theme[:primary_text] %>;"><%= @user.full_name %></strong>,
19
20
  </p>
20
21
 
21
- <p style="margin: 0 0 30px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
22
+ <p style="margin: 0 0 30px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
22
23
  Thank you for joining us! To complete your registration and verify your email address, please use the verification code below:
23
24
  </p>
24
25
 
25
26
  <!-- Verification Code Box -->
26
27
  <table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
27
28
  <tr>
28
- <td align="center" style="padding: 30px; background: linear-gradient(135deg, #f6f8fb 0%, #e9ecef 100%); border-radius: 8px; border: 2px dashed #cbd5e0;">
29
- <div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: #667eea; font-family: 'Courier New', monospace; text-align: center;">
29
+ <td align="center" style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 2px dashed <%= theme[:surface_border] %>;">
30
+ <div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: <%= theme[:accent] %>; font-family: 'Courier New', monospace; text-align: center;">
30
31
  <%= @code %>
31
32
  </div>
32
- <p style="margin: 15px 0 0 0; color: #718096; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
33
+ <p style="margin: 15px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
33
34
  Verification Code
34
35
  </p>
35
36
  </td>
36
37
  </tr>
37
38
  </table>
38
39
 
39
- <p style="margin: 30px 0 20px 0; color: #718096; font-size: 14px; line-height: 1.6;">
40
- <strong style="color: #4a5568;">Important:</strong> This code will expire in <%= CommandTower.config.login.plain_text.email_verify.verify_code_link_valid_for.inspect %>. If you didn't request this code, please ignore this email.
40
+ <p style="margin: 30px 0 20px 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
41
+ <strong style="color: <%= theme[:body_text] %>;">Important:</strong> This code will expire in <%= CommandTower.config.login.plain_text.email_verify.verify_code_link_valid_for.inspect %>. If you didn't request this code, please ignore this email.
41
42
  </p>
42
43
  </td>
43
44
  </tr>
44
45
 
45
46
  <!-- Footer -->
46
47
  <tr>
47
- <td style="padding: 30px; background-color: #f7fafc; border-top: 1px solid #e2e8f0;">
48
- <p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px; text-align: center; line-height: 1.6;">
48
+ <td style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-top: 1px solid <%= theme[:surface_border] %>;">
49
+ <p style="margin: 0 0 15px 0; color: <%= theme[:body_text] %>; font-size: 14px; text-align: center; line-height: 1.6;">
49
50
  Best regards,<br>
50
- <strong style="color: #667eea;"><%= CommandTower.config.app.communication_name %> Team</strong>
51
+ <strong style="color: <%= theme[:accent] %>;"><%= CommandTower.config.app.communication_name %> Team</strong>
51
52
  </p>
52
53
 
53
54
  <p style="margin: 0; text-align: center;">
54
- <a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: #667eea; text-decoration: none; font-size: 14px; font-weight: 500;">
55
+ <a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: <%= theme[:primary_action] %>; text-decoration: none; font-size: 14px; font-weight: 500;">
55
56
  Visit <%= CommandTower.config.app.communication_name %>
56
57
  </a>
57
58
  </p>
@@ -63,7 +64,7 @@
63
64
  <table width="600" cellpadding="0" cellspacing="0" border="0" style="margin-top: 20px;">
64
65
  <tr>
65
66
  <td style="text-align: center; padding: 20px 0;">
66
- <p style="margin: 0; color: #a0aec0; font-size: 12px; line-height: 1.5;">
67
+ <p style="margin: 0; color: <%= theme[:muted_text] %>; font-size: 12px; line-height: 1.5;">
67
68
  This is an automated message. Please do not reply to this email.
68
69
  </p>
69
70
  </td>
@@ -1,21 +1,22 @@
1
- <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f5f7; padding: 32px 16px;">
1
+ <% theme = CommandTower::EmailTheme::Resolver.resolve -%>
2
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 32px 16px;">
2
3
  <tr>
3
4
  <td align="center">
4
- <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;">
5
+ <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border: 1px solid <%= theme[:surface_border] %>; border-radius: 8px; overflow: hidden;">
5
6
  <tr>
6
7
  <td style="padding: 28px 28px 8px 28px;">
7
- <h1 style="margin: 0; color: #1a202c; font-size: 22px; font-weight: 600; line-height: 1.35;">
8
+ <h1 style="margin: 0; color: <%= theme[:primary_text] %>; font-size: 22px; font-weight: 600; line-height: 1.35;">
8
9
  <%= h.call(title) %>
9
10
  </h1>
10
11
  </td>
11
12
  </tr>
12
13
  <tr>
13
14
  <td style="padding: 8px 28px 28px 28px;">
14
- <p style="margin: 0; color: #4a5568; font-size: 16px; line-height: 1.6; white-space: pre-wrap;">
15
+ <p style="margin: 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6; white-space: pre-wrap;">
15
16
  <%= h.call(body) %></p>
16
17
  <% if deep_link -%>
17
18
  <p style="margin: 24px 0 0 0;">
18
- <a href="<%= h.call(deep_link) %>" style="color: #2b6cb0; font-size: 14px; text-decoration: underline;">
19
+ <a href="<%= h.call(deep_link) %>" style="color: <%= theme[:primary_action] %>; font-size: 14px; text-decoration: underline;">
19
20
  <%= h.call(deep_link) %>
20
21
  </a>
21
22
  </p>
@@ -1,11 +1,12 @@
1
- <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f7fa; padding: 40px 20px;">
1
+ <% theme = CommandTower::EmailTheme::Resolver.resolve -%>
2
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: <%= theme[:canvas_background] %>; padding: 40px 20px;">
2
3
  <tr>
3
4
  <td align="center">
4
- <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
5
+ <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: <%= theme[:surface_background] %>; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden;">
5
6
  <!-- Header -->
6
7
  <tr>
7
- <td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
8
- <h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
8
+ <td style="background: <%= theme[:accent] %>; padding: 40px 30px; text-align: center;">
9
+ <h1 style="margin: 0; color: <%= theme[:text_on_accent] %>; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
9
10
  Reset Your Password
10
11
  </h1>
11
12
  </td>
@@ -14,22 +15,22 @@
14
15
  <!-- Content -->
15
16
  <tr>
16
17
  <td style="padding: 40px 30px;">
17
- <p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
18
- Hello <strong style="color: #2d3748;"><%= @user.full_name %></strong>,
18
+ <p style="margin: 0 0 20px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
19
+ Hello <strong style="color: <%= theme[:primary_text] %>;"><%= @user.full_name %></strong>,
19
20
  </p>
20
21
 
21
- <p style="margin: 0 0 30px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
22
+ <p style="margin: 0 0 30px 0; color: <%= theme[:body_text] %>; font-size: 16px; line-height: 1.6;">
22
23
  We received a request to reset your password for your <%= CommandTower.config.app.communication_name %> account. Click the button below to reset your password:
23
24
  </p>
24
25
 
25
26
  <!-- Reset Token Box -->
26
27
  <table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0;">
27
28
  <tr>
28
- <td align="center" style="padding: 30px; background: linear-gradient(135deg, #f6f8fb 0%, #e9ecef 100%); border-radius: 8px; border: 2px dashed #cbd5e0;">
29
- <div style="font-size: 18px; font-weight: 600; color: #667eea; font-family: 'Courier New', monospace; text-align: center; word-break: break-all; padding: 10px;">
29
+ <td align="center" style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 2px dashed <%= theme[:surface_border] %>;">
30
+ <div style="font-size: 18px; font-weight: 600; color: <%= theme[:accent] %>; font-family: 'Courier New', monospace; text-align: center; word-break: break-all; padding: 10px;">
30
31
  <%= @token %>
31
32
  </div>
32
- <p style="margin: 15px 0 0 0; color: #718096; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
33
+ <p style="margin: 15px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">
33
34
  Reset Token
34
35
  </p>
35
36
  </td>
@@ -42,30 +43,30 @@
42
43
  <td align="center" style="padding: 15px 0;">
43
44
  <% reset_url = "#{CommandTower.config.app.composed_url}#{@reset_password_path}?token=#{@token}" %>
44
45
  <% reset_url += "&email=#{CGI.escape(@email)}" if @require_email %>
45
- <a href="<%= reset_url %>" style="display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px; text-align: center;">
46
+ <a href="<%= reset_url %>" style="display: inline-block; padding: 14px 32px; background-color: <%= theme[:primary_action] %>; color: <%= theme[:text_on_accent] %>; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px; text-align: center;">
46
47
  Reset Password
47
48
  </a>
48
49
  </td>
49
50
  </tr>
50
51
  </table>
51
52
 
52
- <p style="margin: 30px 0 20px 0; color: #718096; font-size: 14px; line-height: 1.6;">
53
- <strong style="color: #4a5568;">Important:</strong> This link will expire in <%= CommandTower.config.login.plain_text.password_reset.token_valid_for.inspect %>. If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
53
+ <p style="margin: 30px 0 20px 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
54
+ <strong style="color: <%= theme[:body_text] %>;">Important:</strong> This link will expire in <%= CommandTower.config.login.plain_text.password_reset.token_valid_for.inspect %>. If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
54
55
  </p>
55
56
 
56
- <p style="margin: 20px 0 0 0; color: #718096; font-size: 14px; line-height: 1.6;">
57
+ <p style="margin: 20px 0 0 0; color: <%= theme[:muted_text] %>; font-size: 14px; line-height: 1.6;">
57
58
  If the button above doesn't work, copy and paste the reset token above into the password reset form on our website.
58
59
  </p>
59
60
 
60
61
  <!-- Alternative Reset URL Link -->
61
62
  <table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 30px 0 0 0;">
62
63
  <tr>
63
- <td align="center" style="padding: 20px; background-color: #f7fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
64
- <p style="margin: 0 0 10px 0; color: #4a5568; font-size: 14px; font-weight: 600;">
64
+ <td align="center" style="padding: 20px; background-color: <%= theme[:canvas_background] %>; border-radius: 8px; border: 1px solid <%= theme[:surface_border] %>;">
65
+ <p style="margin: 0 0 10px 0; color: <%= theme[:body_text] %>; font-size: 14px; font-weight: 600;">
65
66
  Or visit the reset password page directly:
66
67
  </p>
67
- <p style="margin: 0; color: #667eea; font-size: 14px; word-break: break-all;">
68
- <a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color: #667eea; text-decoration: underline;">
68
+ <p style="margin: 0; color: <%= theme[:primary_action] %>; font-size: 14px; word-break: break-all;">
69
+ <a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color: <%= theme[:primary_action] %>; text-decoration: underline;">
69
70
  <%= CommandTower.config.app.composed_url %><%= @reset_password_path %>
70
71
  </a>
71
72
  </p>
@@ -77,14 +78,14 @@
77
78
 
78
79
  <!-- Footer -->
79
80
  <tr>
80
- <td style="padding: 30px; background-color: #f7fafc; border-top: 1px solid #e2e8f0;">
81
- <p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px; text-align: center; line-height: 1.6;">
81
+ <td style="padding: 30px; background-color: <%= theme[:canvas_background] %>; border-top: 1px solid <%= theme[:surface_border] %>;">
82
+ <p style="margin: 0 0 15px 0; color: <%= theme[:body_text] %>; font-size: 14px; text-align: center; line-height: 1.6;">
82
83
  Best regards,<br>
83
- <strong style="color: #667eea;"><%= CommandTower.config.app.communication_name %> Team</strong>
84
+ <strong style="color: <%= theme[:accent] %>;"><%= CommandTower.config.app.communication_name %> Team</strong>
84
85
  </p>
85
86
 
86
87
  <p style="margin: 0; text-align: center;">
87
- <a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: #667eea; text-decoration: none; font-size: 14px; font-weight: 500;">
88
+ <a href="<%= CommandTower.config.app.composed_url %>" target="_blank" style="color: <%= theme[:primary_action] %>; text-decoration: none; font-size: 14px; font-weight: 500;">
88
89
  Visit <%= CommandTower.config.app.communication_name %>
89
90
  </a>
90
91
  </p>
@@ -96,7 +97,7 @@
96
97
  <table width="600" cellpadding="0" cellspacing="0" border="0" style="margin-top: 20px;">
97
98
  <tr>
98
99
  <td style="text-align: center; padding: 20px 0;">
99
- <p style="margin: 0; color: #a0aec0; font-size: 12px; line-height: 1.5;">
100
+ <p style="margin: 0; color: <%= theme[:muted_text] %>; font-size: 12px; line-height: 1.5;">
100
101
  This is an automated message. Please do not reply to this email.
101
102
  </p>
102
103
  </td>
@@ -5,6 +5,8 @@ module CommandTower
5
5
  module Auth
6
6
  module PlainText
7
7
  class LoginWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ include CommandTower::Workflows::ClientCompatibility::RecommendationMeta
9
+
8
10
  retry_strategy :none
9
11
 
10
12
  def call(input:, request_context: nil)
@@ -24,6 +26,7 @@ module CommandTower
24
26
  token_expires_at: data[:expires_at]
25
27
  ),
26
28
  http_status: :created,
29
+ meta: client_compatibility_meta,
27
30
  response_effects: {
28
31
  set_token: { token: data[:token], expires_at: data[:expires_at] },
29
32
  ensure_csrf_cookie: { rotate: csrf_rotate_on_login? }
@@ -5,6 +5,8 @@ module CommandTower
5
5
  module Auth
6
6
  module Session
7
7
  class ShowWorkflow < CommandTower::Workflows::ApplicationWorkflow
8
+ include CommandTower::Workflows::ClientCompatibility::RecommendationMeta
9
+
8
10
  retry_strategy :none
9
11
 
10
12
  def call(current_user:, auth_context:)
@@ -30,6 +32,7 @@ module CommandTower
30
32
  actor: auth_context.actor_user
31
33
  ),
32
34
  http_status: :ok,
35
+ meta: client_compatibility_meta,
33
36
  response_effects: response_effects
34
37
  )
35
38
  end