command_tower 0.16.0 → 0.17.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/application_controller.rb +1 -0
  3. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  4. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
  6. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  7. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  8. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  9. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  10. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
  11. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  12. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  13. data/app/services/command_tower/services/messaging/inbox.rb +8 -1
  14. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  15. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  16. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  17. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  18. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  19. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  20. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  21. data/docs/api_reference.md +13 -2
  22. data/docs/extending.md +2 -1
  23. data/docs/host_integration_guide.md +35 -0
  24. data/docs/messaging_integration_guide.md +38 -0
  25. data/docs/upgrades/0.17.0.md +33 -0
  26. data/docs/upgrades/README.md +1 -0
  27. data/lib/command_tower/client_compatibility/version.rb +45 -0
  28. data/lib/command_tower/client_compatibility.rb +23 -0
  29. data/lib/command_tower/configuration/config.rb +6 -0
  30. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  31. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  32. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  33. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  34. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  35. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  36. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  37. data/lib/command_tower/configuration/registry/config.rb +14 -0
  38. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  39. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  40. data/lib/command_tower/current.rb +1 -0
  41. data/lib/command_tower/engine.rb +4 -0
  42. data/lib/command_tower/inbox_presentations.rb +19 -0
  43. data/lib/command_tower/version.rb +1 -1
  44. metadata +24 -2
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Internal Execution-pipeline collaborator (not a Service/Workflow/Controller).
9
+ #
10
+ # Resolves one rendered-destination basename (e.g. "email", "sms") to an
11
+ # ActionView template, preferring a `notification_type_key`-specific view
12
+ # over the generic one, and preferring the host application's `app/views`
13
+ # over CommandTower's own engine views for either.
14
+ #
15
+ # Host convention (documentation, not code):
16
+ #
17
+ # app/views/command_tower/messaging/rendering/
18
+ # email.html.erb # optional host override of the generic chrome
19
+ # <notification_type_key>/email.html.erb # optional type-specific override
20
+ #
21
+ # Hosts customize by dropping ERB files at that path; they never call this
22
+ # class directly. `ChannelRenderer` remains the only public Ruby entry into
23
+ # rendering. `prepend_view_path`/`reset_view_paths!` exist solely as a
24
+ # messaging spec fixture seam (mirrors Rails' own `prepend_view_path`
25
+ # testing idiom) and are not part of any host-facing contract.
26
+ class TemplateResolver
27
+ TEMPLATE_PREFIX = "command_tower/messaging/rendering"
28
+ TYPE_KEY_PATTERN = /\A[a-z0-9_]+\z/
29
+
30
+ class << self
31
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
32
+ new.render(
33
+ basename:,
34
+ format:,
35
+ notification_type_key:,
36
+ generic_locals:,
37
+ type_locals:,
38
+ )
39
+ end
40
+
41
+ # Spec-only seam: prepend an additional view root ahead of the host
42
+ # and engine defaults so fixture views can prove override precedence.
43
+ def prepend_view_path(path)
44
+ extra_view_paths.unshift(path.to_s)
45
+ end
46
+
47
+ def reset_view_paths!
48
+ extra_view_paths.clear
49
+ end
50
+
51
+ # Type-only, no-generic-fallback resolution: returns `nil` when
52
+ # there is no matching type template (invalid/sanitized-out key, or
53
+ # the type directory/basename simply does not exist) instead of
54
+ # raising `Errno::ENOENT` like `.render` does. Callers that have no
55
+ # generic ERB file to fall back to (e.g. `InboxDocumentRenderer`,
56
+ # whose generic path is a pure-Ruby builder) use this instead of
57
+ # `.render`. A template that is *found* but raises mid-render still
58
+ # propagates (via `render_named`'s `ActionView::Template::Error`
59
+ # unwrap) so callers can distinguish "no type content" from "type
60
+ # content is broken" if they choose to.
61
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
62
+ new.render_type_template(basename:, format:, notification_type_key:, locals:)
63
+ end
64
+
65
+ def extra_view_paths
66
+ @extra_view_paths ||= []
67
+ end
68
+ end
69
+
70
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
71
+ type_key = sanitized_type_key(notification_type_key)
72
+
73
+ if type_key
74
+ begin
75
+ return render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, type_locals)
76
+ rescue ActionView::MissingTemplate
77
+ # No type-specific template for this basename; fall through to generic.
78
+ end
79
+ end
80
+
81
+ begin
82
+ render_named("#{TEMPLATE_PREFIX}/#{basename}", format, generic_locals)
83
+ rescue ActionView::MissingTemplate
84
+ raise Errno::ENOENT, "missing template #{basename}.#{format}.erb"
85
+ end
86
+ end
87
+
88
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
89
+ type_key = sanitized_type_key(notification_type_key)
90
+ return nil unless type_key
91
+
92
+ render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, locals)
93
+ rescue ActionView::MissingTemplate
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def sanitized_type_key(notification_type_key)
100
+ key = notification_type_key.to_s
101
+ key.match?(TYPE_KEY_PATTERN) ? key : nil
102
+ end
103
+
104
+ def render_named(template_name, format, locals)
105
+ view.render(template: template_name, formats: [format], locals:)
106
+ rescue ActionView::Template::Error => error
107
+ # ActionView wraps any error raised while executing a *found*
108
+ # template in `ActionView::Template::Error`. Legacy plain-ERB
109
+ # rendering never wrapped errors, so unwrap here to preserve the
110
+ # original `error_class` that `ChannelRenderer#render`'s outer
111
+ # rescue surfaces on `RenderError`.
112
+ raise(error.cause || error)
113
+ end
114
+
115
+ def view
116
+ @view ||= ActionView::Base.with_empty_template_cache.with_view_paths(view_paths)
117
+ end
118
+
119
+ def view_paths
120
+ self.class.extra_view_paths + [
121
+ Rails.root.join("app/views").to_s,
122
+ CommandTower::Engine.root.join("app/views").to_s,
123
+ ]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -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
@@ -120,7 +120,14 @@ module CommandTower
120
120
  communication = CommandTower::Messaging::Contract::Communications.find(
121
121
  CommandTower::Messaging::Contract::Requests::FindCommunication.build(communication_id: item.communication_id, recipient_id:)
122
122
  )
123
- item(item).merge(body: communication.body, metadata: communication.metadata, notification_type_key: communication.notification_type_key)
123
+ content = CommandTower::Messaging::Rendering::InboxDocumentRenderer.render(communication:, recipient_id:).to_h
124
+
125
+ item(item).merge(
126
+ body: communication.body,
127
+ metadata: communication.metadata,
128
+ notification_type_key: communication.notification_type_key,
129
+ content:,
130
+ )
124
131
  end
125
132
  end
126
133
  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