studio-engine 0.5.3 → 0.5.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8a6751151b77737dbf25bd04a59d9ad3f801ec8f66c214dc5a97ef5f006b1f6
4
- data.tar.gz: f0283ba8e4e38b8e2375b563b6f93917c3ffbc3a0880353c62ec2c5f9441b6f4
3
+ metadata.gz: 5ceb657a1e35df5801b4bf45a82d916d236d5497bd876b9b245997474411c310
4
+ data.tar.gz: 6d68e9c3fb7a338b22adf45dd00435d21e16ddfce4321c5e28eabd9108a93ba1
5
5
  SHA512:
6
- metadata.gz: a911d76518bba342d65e2e5cb1d32c80f28a484057deb8ea630a2aae34867a530b51f0814abedee21267ff66a03df2c71e4c5610b24248e8cb5174b289f92da5
7
- data.tar.gz: '09a3b96acf18c3cd876463445eb02f704b25292f048006be18c44aeaffe2e9e152d1f42977bad66cde5834c87a0eda05489ebbf1b53cdb8c7ab6ca1f5a1d3d7b'
6
+ metadata.gz: d2ffbe056170f338dca804feeaf5594aa470d0f26488514ba798403108d6091e3072fa58b92a2fb8908c14d14d26cff7620fdbfcb8cd04a4f984316d90ec05b2
7
+ data.tar.gz: 550d7dd3d351624c058d0a4db421be26c18dc0b357af54c3e9c575164bd78b908798e7e66fe87cd64b3dde648372337938ca4f7b31c52f8438676f58ba5c8d90
data/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  No entries yet.
8
8
 
9
+ ## v0.5.5 (2026-06-14)
10
+
11
+ ### Added
12
+ - **Local email inbox** at `/_studio/local_emails` for non-production localhost requests. It lists recent outbox rows and exposes local proof links for magic-link, email-verification, wallet-export, and email-change emails.
13
+ - **`Studio.local_email_capture?`** — shared capture switch for local/worktree stacks. `LOCAL_EMAIL_CAPTURE=1` or `AGENT_WORKTREE=1` records delivery rows without enqueueing external sends.
14
+
15
+ ## v0.5.4 (2026-06-14)
16
+
17
+ ### Changed
18
+ - Engine magic links are now scanner-safe: emailed links land on an inert GET confirmation page, and the token is consumed only by the CSRF-protected POST from that page.
19
+ - Added the shared `magic_link_consume_path` route helper for consumer apps using engine-drawn auth routes.
20
+
9
21
  ## v0.5.3 (2026-06-14)
10
22
 
11
23
  ### Added
data/README.md CHANGED
@@ -11,7 +11,7 @@ Shared Rails engine for McRitchie apps. Provides authentication, error handling,
11
11
  gem "studio-engine", "~> 0.5"
12
12
  ```
13
13
 
14
- Then `bundle install`. The current release is **v0.5.2**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
14
+ Then `bundle install`. The current release is **v0.5.5**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
15
15
 
16
16
  > Published to RubyGems as of v0.4.0 (2026-05-17). New installs should use the RubyGems form, which the consumer Rails apps (`mcritchie-studio`, `turf-monster`) already use.
17
17
 
@@ -62,7 +62,9 @@ Rails.application.routes.draw do
62
62
  end
63
63
  ```
64
64
 
65
- This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link routes, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`.
65
+ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link request/confirm/consume routes, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Magic-link emails point at the inert GET confirmation route; the single-use token is consumed only by the CSRF-protected POST to `magic_link_consume_path`.
66
+
67
+ In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
66
68
 
67
69
  ## Overriding Views
68
70
 
@@ -1,7 +1,8 @@
1
1
  # Unified create-or-login email magic link (the passwordless email path).
2
2
  #
3
3
  # POST /magic_link — request a link (email [, return_to])
4
- # GET /magic_link/:token — consume it: log in OR create the account
4
+ # GET /magic_link/:token — "Confirm sign-in" interstitial (does NOT consume)
5
+ # POST /magic_link/:token — consume it: log in OR create the account
5
6
  #
6
7
  # create-or-login: clicking the link IS proof of email ownership, so an email
7
8
  # that collides with a Google/wallet-only account that was never email-verified
@@ -14,6 +15,7 @@
14
15
  # sign_in_existing / sign_up_new building blocks.
15
16
  class MagicLinksController < ApplicationController
16
17
  skip_before_action :require_authentication
18
+ layout false, only: :confirm
17
19
 
18
20
  # Respond uniformly for any well-formed email. Under create-or-login every
19
21
  # address is "valid" (it logs in or signs up), so there is nothing to
@@ -30,13 +32,22 @@ class MagicLinksController < ApplicationController
30
32
  end
31
33
  end
32
34
 
35
+ # GET /magic_link/:token is deliberately inert. Email link scanners and link
36
+ # preview clients frequently prefetch emailed URLs with GET/HEAD; if GET burned
37
+ # the token, the human's first real click could already be invalid. The page
38
+ # renders a CSRF-protected form that a browser auto-POSTs to #consume.
39
+ def confirm
40
+ # strict-origin strips the token-bearing path from subresource Referer
41
+ # headers while preserving a usable Origin header for Rails' CSRF origin
42
+ # check on the consume POST.
43
+ response.set_header("Referrer-Policy", "strict-origin")
44
+ @token = params[:token]
45
+ end
46
+
47
+ # POST /magic_link/:token is the authoritative consume. This is the only place
48
+ # the single-use token is burned.
33
49
  def consume
34
- # Keep the token out of Referer headers on the consume page's subresource
35
- # loads. Single-use + short TTL is the primary defence; this closes the
36
- # passive-leak gap. NOTE: aggressive email link-scanners (Outlook SafeLinks,
37
- # Mimecast) may pre-fetch the link and burn the single-use token before the
38
- # human clicks — a known magic-link tradeoff; documented for support.
39
- response.set_header("Referrer-Policy", "no-referrer")
50
+ response.set_header("Referrer-Policy", "strict-origin")
40
51
  result = MagicLink.consume(params[:token])
41
52
  user = User.find_by(email: result.email)
42
53
  user ? sign_in_existing(user, result) : sign_up_new(result)
@@ -0,0 +1,108 @@
1
+ module Studio
2
+ class LocalEmailsController < ApplicationController
3
+ skip_before_action :require_authentication, raise: false
4
+ layout false
5
+
6
+ before_action :require_local_development!
7
+
8
+ def index
9
+ @deliveries = delivery_records.map { |record| serialize_delivery(record) }
10
+
11
+ respond_to do |format|
12
+ format.html
13
+ format.json do
14
+ render json: {
15
+ capture_enabled: Studio.local_email_capture?,
16
+ inbox_url: request.original_url.sub(/\.json\z/, ""),
17
+ deliveries: @deliveries
18
+ }
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def require_local_development!
26
+ head :not_found if Rails.env.production? || !request.local?
27
+ end
28
+
29
+ def delivery_records
30
+ klass = delivery_class
31
+ return [] unless klass
32
+
33
+ klass.recent.limit(50)
34
+ end
35
+
36
+ def delivery_class
37
+ if Object.const_defined?(:EmailDelivery, false)
38
+ return Object.const_get(:EmailDelivery, false)
39
+ end
40
+
41
+ if Studio.const_defined?(:EmailDelivery, false) && Studio::EmailDelivery.respond_to?(:available?) && Studio::EmailDelivery.available?
42
+ return Studio::EmailDelivery
43
+ end
44
+
45
+ nil
46
+ end
47
+
48
+ def serialize_delivery(record)
49
+ args = deserialize_args(record.args)
50
+ kwargs = deserialize_kwargs(record.kwargs)
51
+ {
52
+ id: record.id,
53
+ email_key: record.email_key,
54
+ mailer: record.mailer,
55
+ action: record.action,
56
+ to: record.to,
57
+ sent: record.sent?,
58
+ sent_at: record.sent_at,
59
+ error: record.error,
60
+ created_at: record.created_at,
61
+ action_url: local_action_url(record, args),
62
+ args_preview: args.map { |arg| preview_value(arg) },
63
+ kwargs_preview: kwargs.transform_values { |value| preview_value(value) }
64
+ }
65
+ end
66
+
67
+ def deserialize_args(value)
68
+ ActiveJob::Arguments.deserialize(value || [])
69
+ rescue StandardError
70
+ []
71
+ end
72
+
73
+ def deserialize_kwargs(value)
74
+ deserialized = ActiveJob::Arguments.deserialize([value || {}]).first
75
+ deserialized.respond_to?(:symbolize_keys) ? deserialized.symbolize_keys : {}
76
+ rescue StandardError
77
+ {}
78
+ end
79
+
80
+ def local_action_url(record, args)
81
+ token = args[1].to_s
82
+ return if token.empty?
83
+
84
+ path =
85
+ case record.email_key
86
+ when /#magic_link\z/
87
+ "/magic_link/#{ERB::Util.url_encode(token)}"
88
+ when /#email_verification\z/
89
+ "/email_verification/#{ERB::Util.url_encode(token)}"
90
+ when /#wallet_export\z/
91
+ "/account/wallet/export/#{ERB::Util.url_encode(token)}"
92
+ when /#email_change_confirmation\z/
93
+ "/account/email/confirm/#{ERB::Util.url_encode(token)}"
94
+ end
95
+
96
+ "#{request.base_url}#{path}" if path
97
+ end
98
+
99
+ def preview_value(value)
100
+ case value
101
+ when String, Numeric, TrueClass, FalseClass, NilClass
102
+ value
103
+ else
104
+ value.respond_to?(:to_global_id) ? value.to_global_id.to_s : value.to_s
105
+ end
106
+ end
107
+ end
108
+ end
@@ -23,12 +23,13 @@ module Studio
23
23
  args: ActiveJob::Arguments.serialize(args),
24
24
  kwargs: ActiveJob::Arguments.serialize([kwargs]).first
25
25
  )
26
- Studio::EmailDeliveryJob.perform_later(record.id)
26
+ Studio::EmailDeliveryJob.perform_later(record.id) unless Studio.local_email_capture?
27
27
  record
28
28
  end
29
29
 
30
30
  def deliver_now!
31
31
  return if sent?
32
+ return update!(error: "local email capture enabled; not sent") if Studio.local_email_capture?
32
33
 
33
34
  pos = ActiveJob::Arguments.deserialize(args)
34
35
  kw = ActiveJob::Arguments.deserialize([kwargs]).first.symbolize_keys
@@ -0,0 +1,96 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <title>Signing you in - <%= Studio.app_name %></title>
8
+ <style>
9
+ @keyframes magic-spin { to { transform: rotate(360deg); } }
10
+
11
+ * { box-sizing: border-box; }
12
+
13
+ body {
14
+ margin: 0;
15
+ min-height: 100vh;
16
+ display: grid;
17
+ place-items: center;
18
+ color: #f8fafc;
19
+ background: <%= Studio.theme_dark %>;
20
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
+ }
22
+
23
+ main { width: min(100% - 32px, 420px); text-align: center; }
24
+
25
+ .magic-spinner {
26
+ width: 88px;
27
+ height: 88px;
28
+ margin: 0 auto;
29
+ border-radius: 9999px;
30
+ border: 8px solid rgba(255, 255, 255, 0.14);
31
+ border-top-color: <%= Studio.theme_success %>;
32
+ animation: magic-spin 0.8s linear infinite;
33
+ }
34
+
35
+ .magic-fallback {
36
+ display: none;
37
+ margin-top: 2rem;
38
+ color: rgba(248, 250, 252, 0.8);
39
+ }
40
+
41
+ .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
42
+
43
+ .magic-submit {
44
+ display: inline-block;
45
+ max-width: 100%;
46
+ padding: 14px 34px;
47
+ border: 0;
48
+ border-radius: 8px;
49
+ color: #ffffff;
50
+ background: <%= Studio.theme_success %>;
51
+ font: inherit;
52
+ font-size: 16px;
53
+ font-weight: 700;
54
+ cursor: pointer;
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <main>
60
+ <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
61
+
62
+ <div id="magic-fallback" class="magic-fallback">
63
+ <p>Taking longer than expected?</p>
64
+ <%= button_to magic_link_consume_path(token: @token),
65
+ method: :post,
66
+ form: { id: "magic-consume-form", data: { turbo: "false" } },
67
+ class: "magic-submit" do %>
68
+ Sign in to <%= Studio.app_name %>
69
+ <% end %>
70
+ </div>
71
+ </main>
72
+
73
+ <noscript>
74
+ <style>
75
+ #magic-fallback { display: block !important; }
76
+ .magic-spinner { display: none; }
77
+ </style>
78
+ </noscript>
79
+
80
+ <script>
81
+ (function () {
82
+ var form = document.getElementById("magic-consume-form");
83
+ if (form && !form.dataset.autoSubmitted) {
84
+ form.dataset.autoSubmitted = "1";
85
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
86
+ else form.submit();
87
+ }
88
+
89
+ setTimeout(function () {
90
+ var fallback = document.getElementById("magic-fallback");
91
+ if (fallback) fallback.style.display = "block";
92
+ }, 4000);
93
+ })();
94
+ </script>
95
+ </body>
96
+ </html>
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Local Emails - <%= Studio.app_name %></title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ margin: 0;
11
+ background: #10101f;
12
+ color: #f8fafc;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+ main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
16
+ header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
17
+ h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; }
18
+ p { margin: 0; color: #b6bdd3; }
19
+ .pill { display: inline-flex; align-items: center; border: 1px solid #3b3b62; border-radius: 999px; padding: 6px 12px; color: #d8dcf0; background: #18182b; font-size: 13px; white-space: nowrap; }
20
+ .notice { margin-bottom: 18px; border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 14px 16px; color: #cbd2e8; }
21
+ table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 8px; background: #18182b; }
22
+ th, td { padding: 12px 14px; border-bottom: 1px solid #2f2f4d; text-align: left; vertical-align: top; }
23
+ th { color: #9fa7c3; font-size: 12px; text-transform: uppercase; }
24
+ td { color: #eef2ff; font-size: 14px; }
25
+ tr:last-child td { border-bottom: 0; }
26
+ code { color: #c4b5fd; word-break: break-word; }
27
+ .muted { color: #9fa7c3; }
28
+ .error { color: #fda4af; }
29
+ .button { display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; padding: 8px 12px; background: #4baf50; color: #fff; font-weight: 700; text-decoration: none; white-space: nowrap; }
30
+ .empty { border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 24px; color: #cbd2e8; }
31
+ @media (max-width: 760px) {
32
+ header { display: block; }
33
+ .pill { margin-top: 14px; }
34
+ table, thead, tbody, tr, th, td { display: block; }
35
+ thead { display: none; }
36
+ tr { border-bottom: 1px solid #2f2f4d; padding: 10px 0; }
37
+ td { border-bottom: 0; padding: 8px 14px; }
38
+ td::before { content: attr(data-label); display: block; color: #9fa7c3; font-size: 12px; text-transform: uppercase; margin-bottom: 4px; }
39
+ }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <main>
44
+ <header>
45
+ <div>
46
+ <h1>Local Emails</h1>
47
+ <p>Recent outbox rows for this local <%= Studio.app_name %> stack.</p>
48
+ </div>
49
+ <span class="pill">Capture <%= Studio.local_email_capture? ? "enabled" : "disabled" %></span>
50
+ </header>
51
+
52
+ <% if Studio.local_email_capture? %>
53
+ <div class="notice">This stack records email intents and does not send them to external providers.</div>
54
+ <% else %>
55
+ <div class="notice">Capture is disabled. This page still shows recent delivery rows, but this stack may send real email when workers run.</div>
56
+ <% end %>
57
+
58
+ <% if @deliveries.empty? %>
59
+ <div class="empty">No email deliveries have been recorded yet.</div>
60
+ <% else %>
61
+ <table>
62
+ <thead>
63
+ <tr>
64
+ <th>When</th>
65
+ <th>Message</th>
66
+ <th>To</th>
67
+ <th>Status</th>
68
+ <th>Proof URL</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ <% @deliveries.each do |delivery| %>
73
+ <tr>
74
+ <td data-label="When"><%= delivery[:created_at]&.strftime("%Y-%m-%d %H:%M:%S") %></td>
75
+ <td data-label="Message"><code><%= delivery[:email_key] %></code></td>
76
+ <td data-label="To"><%= delivery[:to] %></td>
77
+ <td data-label="Status">
78
+ <% if delivery[:sent] %>
79
+ Sent
80
+ <% elsif delivery[:error].present? %>
81
+ <span class="error"><%= delivery[:error] %></span>
82
+ <% else %>
83
+ <span class="muted">Captured</span>
84
+ <% end %>
85
+ </td>
86
+ <td data-label="Proof URL">
87
+ <% if delivery[:action_url].present? %>
88
+ <a class="button" href="<%= delivery[:action_url] %>">Open link</a>
89
+ <% else %>
90
+ <span class="muted">No local action URL</span>
91
+ <% end %>
92
+ </td>
93
+ </tr>
94
+ <% end %>
95
+ </tbody>
96
+ </table>
97
+ <% end %>
98
+ </main>
99
+ </body>
100
+ </html>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.5.3"
2
+ VERSION = "0.5.5"
3
3
  end
data/lib/studio.rb CHANGED
@@ -41,6 +41,10 @@ module Studio
41
41
  # verified sending address in config/initializers/studio.rb.
42
42
  mattr_accessor :mailer_from, default: nil
43
43
 
44
+ # Local/worktree email capture. nil means "auto": enabled when AGENT_WORKTREE
45
+ # is truthy, otherwise disabled. Production always disables capture.
46
+ mattr_accessor :local_email_capture, default: nil
47
+
44
48
  # Theme role colors (7 roles)
45
49
  mattr_accessor :theme_primary, default: "#8E82FE"
46
50
  mattr_accessor :theme_dark, default: "#1A1535"
@@ -88,6 +92,13 @@ module Studio
88
92
  auth_methods.include?(method.to_sym)
89
93
  end
90
94
 
95
+ def self.local_email_capture?
96
+ return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
97
+ return !!local_email_capture unless local_email_capture.nil?
98
+
99
+ env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
100
+ end
101
+
91
102
  # Verifies that the host app's User model satisfies the engine's expected
92
103
  # contract. Raises Studio::UserContractError with a clear pointer to
93
104
  # docs/USER_CONTRACT.md if anything required is missing. Called from
@@ -146,6 +157,10 @@ module Studio
146
157
  entry ? "/#{entry[:file]}" : nil
147
158
  end
148
159
 
160
+ def self.env_truthy?(value)
161
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
162
+ end
163
+
149
164
  def self.routes(router)
150
165
  router.instance_exec do
151
166
  get "login", to: "sessions#new"
@@ -158,13 +173,21 @@ module Studio
158
173
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
159
174
  get "auth/failure", to: "omniauth_callbacks#failure"
160
175
 
176
+ unless defined?(Rails) && Rails.env.production?
177
+ get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
178
+ end
179
+
161
180
  # Passwordless email (magic link). Helpers: magic_link_request_path (POST
162
- # to request a link) + magic_link_path(token) / magic_link_url(token:)
163
- # (the emailed consume link). The token is a URL-safe MessageVerifier blob
164
- # but the constraint guards against a stray "." segment.
181
+ # to request a link), magic_link_path(token) / magic_link_url(token:)
182
+ # for the emailed GET confirmation page, and magic_link_consume_path(token)
183
+ # for the scanner-safe POST consume. The token is a URL-safe
184
+ # MessageVerifier blob but the constraint guards against a stray "."
185
+ # segment.
165
186
  if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
166
- post "magic_link", to: "magic_links#create", as: :magic_link_request
167
- get "magic_link/:token", to: "magic_links#consume", as: :magic_link,
187
+ post "magic_link", to: "magic_links#create", as: :magic_link_request
188
+ get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
189
+ constraints: { token: %r{[^/]+} }
190
+ post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
168
191
  constraints: { token: %r{[^/]+} }
169
192
  end
170
193
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -150,6 +150,7 @@ files:
150
150
  - app/controllers/schema_controller.rb
151
151
  - app/controllers/sessions_controller.rb
152
152
  - app/controllers/solana_sessions_controller.rb
153
+ - app/controllers/studio/local_emails_controller.rb
153
154
  - app/controllers/theme_settings_controller.rb
154
155
  - app/helpers/studio_theme_helper.rb
155
156
  - app/jobs/error_log_cleanup_job.rb
@@ -184,12 +185,14 @@ files:
184
185
  - app/views/layouts/_navbar.html.erb
185
186
  - app/views/layouts/studio/_flash.html.erb
186
187
  - app/views/layouts/studio/_head.html.erb
188
+ - app/views/magic_links/confirm.html.erb
187
189
  - app/views/navbar/show.html.erb
188
190
  - app/views/registrations/new.html.erb
189
191
  - app/views/schema/index.html.erb
190
192
  - app/views/sessions/_sso_continue.html.erb
191
193
  - app/views/sessions/new.html.erb
192
194
  - app/views/studio/_cropper_assets.html.erb
195
+ - app/views/studio/local_emails/index.html.erb
193
196
  - app/views/studio/modals/_crop_photo.html.erb
194
197
  - app/views/studio/modals/_host.html.erb
195
198
  - app/views/studio/modals/_image_upload.html.erb