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.
- checksums.yaml +4 -4
- data/app/controllers/command_tower/application_controller.rb +1 -0
- data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
- data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +2 -1
- data/app/services/command_tower/email_theme/resolver.rb +45 -0
- data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
- data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +207 -0
- data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
- data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
- data/app/services/command_tower/services/messaging/inbox.rb +8 -1
- data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
- data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
- data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
- data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
- data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
- data/docs/api_reference.md +13 -2
- data/docs/extending.md +2 -1
- data/docs/host_integration_guide.md +35 -0
- data/docs/messaging_integration_guide.md +38 -0
- data/docs/upgrades/0.17.0.md +33 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/client_compatibility/version.rb +45 -0
- data/lib/command_tower/client_compatibility.rb +23 -0
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/email_theme/config.rb +69 -0
- data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
- data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
- data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
- data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
- data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
- data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
- data/lib/command_tower/configuration/registry/config.rb +14 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
- data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
- data/lib/command_tower/current.rb +1 -0
- data/lib/command_tower/engine.rb +4 -0
- data/lib/command_tower/inbox_presentations.rb +19 -0
- data/lib/command_tower/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
8
|
-
<h1 style="margin: 0; color:
|
|
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:
|
|
18
|
-
Hello <strong style="color:
|
|
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:
|
|
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:
|
|
29
|
-
<div style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color:
|
|
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:
|
|
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:
|
|
40
|
-
<strong style="color:
|
|
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:
|
|
48
|
-
<p style="margin: 0 0 15px 0; color:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
8
|
-
<h1 style="margin: 0; color:
|
|
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:
|
|
18
|
-
Hello <strong style="color:
|
|
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:
|
|
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:
|
|
29
|
-
<div style="font-size: 18px; font-weight: 600; color:
|
|
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:
|
|
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:
|
|
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:
|
|
53
|
-
<strong style="color:
|
|
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:
|
|
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:
|
|
64
|
-
<p style="margin: 0 0 10px 0; color:
|
|
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:
|
|
68
|
-
<a href="<%= CommandTower.config.app.composed_url %><%= @reset_password_path %>" style="color:
|
|
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:
|
|
81
|
-
<p style="margin: 0 0 15px 0; color:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|