studio-engine 0.29.1 → 0.31.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +254 -0
  3. data/README.md +5 -4
  4. data/app/assets/tailwind/studio_engine/engine.css +18 -0
  5. data/app/controllers/concerns/studio/error_handling.rb +7 -2
  6. data/app/controllers/concerns/studio/link_consumption.rb +131 -11
  7. data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
  8. data/app/controllers/magic_links_controller.rb +13 -40
  9. data/app/controllers/registrations_controller.rb +3 -1
  10. data/app/controllers/studio/links_controller.rb +17 -19
  11. data/app/controllers/studio/local_reviews_controller.rb +4 -4
  12. data/app/helpers/studio_sidebar_helper.rb +22 -0
  13. data/app/mailers/user_mailer.rb +8 -7
  14. data/app/models/studio/link.rb +55 -2
  15. data/app/views/components/_link_sidebar.html.erb +179 -0
  16. data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
  17. data/app/views/components/_sidebar_panel.html.erb +63 -0
  18. data/app/views/components/_user_nav.html.erb +13 -7
  19. data/app/views/layouts/_navbar.html.erb +29 -5
  20. data/app/views/navbar/show.html.erb +4 -1
  21. data/app/views/studio/_confirm_interstitial.html.erb +4 -3
  22. data/app/views/studio/banners/_button.html.erb +10 -2
  23. data/app/views/studio/banners/_email_status_button.html.erb +25 -6
  24. data/app/views/studio/banners/_environment.html.erb +38 -13
  25. data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
  26. data/lib/studio/environment_banner.rb +56 -0
  27. data/lib/studio/link_resolution.rb +139 -0
  28. data/lib/studio/link_token.rb +13 -4
  29. data/lib/studio/sidebar_sections.rb +34 -0
  30. data/lib/studio/theme_resolver.rb +4 -2
  31. data/lib/studio/version.rb +1 -1
  32. data/lib/studio.rb +108 -28
  33. data/studio-engine.gemspec +5 -5
  34. metadata +14 -9
  35. data/app/services/magic_link.rb +0 -122
  36. data/app/views/magic_links/confirm.html.erb +0 -2
data/lib/studio.rb CHANGED
@@ -1,12 +1,15 @@
1
1
  require "studio/version"
2
2
  require "studio/engine"
3
3
  require "studio/color_scale"
4
+ require "studio/environment_banner"
4
5
  require "studio/theme_resolver"
5
6
  require "studio/ui_primitives"
7
+ require "studio/sidebar_sections"
6
8
  require "studio/username_generator"
7
9
  require "studio/s3"
8
10
  require "studio/image_cache"
9
11
  require "studio/link_token"
12
+ require "studio/link_resolution"
10
13
  require "studio/email"
11
14
  require "studio/email_smoke"
12
15
  require "studio/mail_transport"
@@ -61,18 +64,57 @@ module Studio
61
64
  # feature off (e.g. McRitchie Studio, which ships neither).
62
65
  mattr_accessor :features, default: []
63
66
 
64
- # Magic-link (passwordless email) tuning. token_name keys the MessageVerifier
65
- # purpose; bump it to invalidate every outstanding link. See MagicLink service.
66
- mattr_accessor :magic_link_ttl, default: 15.minutes
67
+ # ---- Sidebar navigation ----
68
+ # Out-of-the-box navigation: the engine navbar mounts a link-sidebar trigger
69
+ # and slide-out panel when the host declares sections here. The default []
70
+ # renders NOTHING, so existing consumers see no change on upgrade until they
71
+ # opt in. Accepts a static Array of section hashes or a callable (receives
72
+ # the view context) for dynamic sections — route helpers, logged_in? walls.
73
+ # Sections flagged admin: true render only for admin? viewers. Shape and
74
+ # resolution rules: lib/studio/sidebar_sections.rb.
75
+ #
76
+ # Studio.configure do |config|
77
+ # config.sidebar_sections = ->(view) {
78
+ # [ { title: "Site", links: [
79
+ # { label: "Home", href: view.root_path, emoji: "🏠" } ] } ]
80
+ # }
81
+ # end
82
+ mattr_accessor :sidebar_sections, default: []
83
+
84
+ # How long a freshly minted magic link stays live.
85
+ mattr_accessor :magic_link_ttl, default: 15.minutes
86
+
87
+ # RETIRED (0.31.0) — kept only so an initializer that still sets it boots.
88
+ # It named the MessageVerifier purpose for the old :signed store, which no
89
+ # longer exists. Delete the line from your initializer.
67
90
  mattr_accessor :magic_link_token_name, default: "magic_link_v1"
68
91
 
69
- # Where magic-link tokens are stored / which URL scheme they use.
70
- # :signed (default) stateless MessageVerifier MagicLink service; URL is
71
- # /magic_link/<long token>. No table needed. Back-compat default.
72
- # :database a Studio::Link row; URL is the short /l/<token>. Requires the
73
- # studio_links table (install the reference migration). The
74
- # unified scheme both apps move to.
75
- mattr_accessor :magic_link_store, default: :signed
92
+ # RETIRED (0.31.0) — magic links are ALWAYS Studio::Link rows now, so this
93
+ # reads :database and nothing else. Assigning :signed raises rather than
94
+ # silently downgrading: that store minted a ~350-character MessageVerifier
95
+ # blob whose EXPIRED form cannot be decoded, so an app on it could not tell
96
+ # whose dead link it was holding — which is exactly the fact
97
+ # Studio::LinkResolution needs to leave a live session alone. Requires the
98
+ # studio_links table, installed by `bin/rails studio_engine:install:migrations`
99
+ # (never hand-copied — a hand copy collides with the task's own copy on
100
+ # `class CreateStudioLinks`).
101
+ mattr_reader :magic_link_store, default: :database
102
+
103
+ # `to_s.to_sym`, not `to_sym`: this runs from an initializer, and nil or an
104
+ # Integer would raise NoMethodError — swallowing the explanation below with a
105
+ # message that says nothing about what to do. A blank falls through to the
106
+ # raise instead, so the operator reads the actual instruction.
107
+ def self.magic_link_store=(value)
108
+ return if value.to_s.to_sym == :database
109
+
110
+ raise ArgumentError,
111
+ "Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
112
+ "Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
113
+ "config/initializers/studio.rb, then install the table with " \
114
+ "`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
115
+ "order, because this raise fires while the initializer loads and no rake task can " \
116
+ "boot until the line is gone."
117
+ end
76
118
 
77
119
  # Whether Studio.routes draws the magic_link + solana wallet routes. An app that
78
120
  # already defines its own auth routes (e.g. turf-monster, which has battle-tested
@@ -210,13 +252,13 @@ module Studio
210
252
  false
211
253
  end
212
254
 
213
- # True when the emailed/inbox magic-link URL is the short /l/<token> — i.e.
214
- # magic links are Studio::Link rows AND this app draws the /l routes. False =
215
- # the legacy /magic_link/<token> path: the :signed store, OR an app on the
216
- # :database store that keeps its own /magic_link route (e.g. turf-monster,
217
- # whose /l is already its landing-page namespace).
255
+ # True when the emailed/inbox magic-link URL is the short /l/<token> — the
256
+ # standard. False means this app draws its own token route instead and owns
257
+ # the matching consume: turf-monster keeps /magic_link/<token> because /l is
258
+ # already its landing-page namespace. Either way the TOKEN is the same short
259
+ # Studio::Link token; only the path in front of it differs.
218
260
  def self.magic_link_via_l_route?
219
- magic_link_store == :database && draw_link_routes
261
+ draw_link_routes
220
262
  end
221
263
 
222
264
  # The floor every developer-desk tool sits on: the local email inbox
@@ -238,6 +280,40 @@ module Studio
238
280
  env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
239
281
  end
240
282
 
283
+ # ---- Shared environment banner ------------------------------------------
284
+ # Rules live in Studio::EnvironmentBanner (pure Ruby, unit-tested); these are
285
+ # the Rails-aware entry points `studio/banners/_environment` calls. A host
286
+ # renders that ONE partial instead of hand-rolling its own strip.
287
+
288
+ # True for a stable QA app: Rails-production, but a non-production review
289
+ # target that must identify itself as one. Keyed off QA_ENV, the signal the
290
+ # release conductor already sets on every QA app.
291
+ def self.qa_environment?
292
+ EnvironmentBanner.qa_environment?
293
+ end
294
+
295
+ def self.show_environment_banner?(rails_env: rails_env_name)
296
+ EnvironmentBanner.show?(rails_env: rails_env, qa_environment: qa_environment?)
297
+ end
298
+
299
+ def self.environment_banner_message(rails_env: rails_env_name, extra: [])
300
+ EnvironmentBanner.message(rails_env: rails_env, qa_environment: qa_environment?, extra: extra)
301
+ end
302
+
303
+ # Whether the local email inbox is actually REACHABLE for this request, which
304
+ # is the only honest reason to render a link to it. Deliberately the same
305
+ # gate the controller enforces (local_tool_enabled?), so the banner can never
306
+ # advertise a page that answers 404 — QA gets a status chip instead.
307
+ def self.local_inbox_reachable?(request_local:)
308
+ local_tool_enabled?(request_local: request_local)
309
+ end
310
+
311
+ def self.rails_env_name
312
+ return "development" unless defined?(Rails) && Rails.respond_to?(:env)
313
+
314
+ Rails.env.to_s
315
+ end
316
+
241
317
  def self.user_wallet_address(user)
242
318
  return nil unless user
243
319
 
@@ -278,7 +354,7 @@ module Studio
278
354
 
279
355
  See the USER_CONTRACT.md doc in the studio-engine repo for the full
280
356
  contract + a minimal compliant example:
281
- https://github.com/amcritchie/studio-engine/blob/main/docs/USER_CONTRACT.md
357
+ https://github.com/McRitchie-Studio/studio-engine/blob/main/docs/USER_CONTRACT.md
282
358
 
283
359
  To bypass this check temporarily, set Studio.validate_user_contract = false
284
360
  in config/initializers/studio.rb.
@@ -309,6 +385,13 @@ module Studio
309
385
  entry ? "/#{entry[:file]}" : nil
310
386
  end
311
387
 
388
+ # Sidebar sections resolved for a view context: a callable config is called
389
+ # with the view, keys symbolize, and admin-only sections drop for non-admin
390
+ # viewers. Rendering gates on `.any?`, so [] keeps the navbar untouched.
391
+ def self.sidebar_sections_for(view)
392
+ SidebarSections.resolve(sidebar_sections, view)
393
+ end
394
+
312
395
  def self.env_truthy?(value)
313
396
  %w[1 true yes on].include?(value.to_s.strip.downcase)
314
397
  end
@@ -333,18 +416,15 @@ module Studio
333
416
  get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
334
417
  end
335
418
 
336
- # Passwordless email (magic link). Helpers: magic_link_request_path (POST
337
- # to request a link), magic_link_path(token) / magic_link_url(token:)
338
- # for the emailed GET confirmation page, and magic_link_consume_path(token)
339
- # for the scanner-safe POST consume. The token is a URL-safe
340
- # MessageVerifier blob but the constraint guards against a stray "."
341
- # segment.
419
+ # Passwordless email (magic link) — the REQUEST half only. Helper:
420
+ # magic_link_request_path (POST an email address, get a link mailed).
421
+ #
422
+ # The token-bearing half moved to /l/<token> below (0.31.0). There is one
423
+ # token format now a short Studio::Link row and one place that burns
424
+ # it, so the old /magic_link/:token confirm+consume pair would have been a
425
+ # second door onto the same lock.
342
426
  if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
343
- post "magic_link", to: "magic_links#create", as: :magic_link_request
344
- get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
345
- constraints: { token: %r{[^/]+} }
346
- post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
347
- constraints: { token: %r{[^/]+} }
427
+ post "magic_link", to: "magic_links#create", as: :magic_link_request
348
428
  end
349
429
 
350
430
  # Unified short-token links — /l/<token> for magic sign-in links + referral
@@ -7,15 +7,15 @@ Gem::Specification.new do |spec|
7
7
  spec.email = ["studio-engine@mcritchie.studio"]
8
8
  spec.summary = "Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed image caching"
9
9
  spec.description = "Studio Engine is a non-isolated Rails engine that ships an opinionated authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern, a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed ImageCache. Used in production across the McRitchie Studio + Turf Monster apps."
10
- spec.homepage = "https://github.com/amcritchie/studio-engine"
10
+ spec.homepage = "https://github.com/McRitchie-Studio/studio-engine"
11
11
  spec.license = "MIT"
12
12
  spec.required_ruby_version = ">= 3.0"
13
13
 
14
14
  spec.metadata = {
15
- "homepage_uri" => "https://github.com/amcritchie/studio-engine",
16
- "source_code_uri" => "https://github.com/amcritchie/studio-engine/tree/main",
17
- "bug_tracker_uri" => "https://github.com/amcritchie/studio-engine/issues",
18
- "changelog_uri" => "https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md"
15
+ "homepage_uri" => "https://github.com/McRitchie-Studio/studio-engine",
16
+ "source_code_uri" => "https://github.com/McRitchie-Studio/studio-engine/tree/main",
17
+ "bug_tracker_uri" => "https://github.com/McRitchie-Studio/studio-engine/issues",
18
+ "changelog_uri" => "https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md"
19
19
  }
20
20
 
21
21
  spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.1
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-08-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -201,6 +201,7 @@ files:
201
201
  - app/controllers/theme_settings_controller.rb
202
202
  - app/helpers/studio/admin_models_table_helper.rb
203
203
  - app/helpers/studio_email_delivery_helper.rb
204
+ - app/helpers/studio_sidebar_helper.rb
204
205
  - app/helpers/studio_theme_helper.rb
205
206
  - app/jobs/error_log_cleanup_job.rb
206
207
  - app/jobs/studio/email_delivery_job.rb
@@ -219,7 +220,6 @@ files:
219
220
  - app/models/studio/model_page.rb
220
221
  - app/models/theme_setting.rb
221
222
  - app/services/google_oauth_validator.rb
222
- - app/services/magic_link.rb
223
223
  - app/services/studio/email_image.rb
224
224
  - app/views/components/_admin_dropdown.html.erb
225
225
  - app/views/components/_avatar.html.erb
@@ -232,7 +232,10 @@ files:
232
232
  - app/views/components/_google_logo.html.erb
233
233
  - app/views/components/_input.html.erb
234
234
  - app/views/components/_json_debug.html.erb
235
+ - app/views/components/_link_sidebar.html.erb
236
+ - app/views/components/_link_sidebar_trigger.html.erb
235
237
  - app/views/components/_progress_bar.html.erb
238
+ - app/views/components/_sidebar_panel.html.erb
236
239
  - app/views/components/_theme_toggle.html.erb
237
240
  - app/views/components/_theme_toggle_morph.html.erb
238
241
  - app/views/components/_user_nav.html.erb
@@ -243,7 +246,6 @@ files:
243
246
  - app/views/layouts/studio/_flash.html.erb
244
247
  - app/views/layouts/studio/_head.html.erb
245
248
  - app/views/layouts/studio/_smooth_load.html.erb
246
- - app/views/magic_links/confirm.html.erb
247
249
  - app/views/navbar/show.html.erb
248
250
  - app/views/registrations/new.html.erb
249
251
  - app/views/schema/index.html.erb
@@ -330,11 +332,14 @@ files:
330
332
  - lib/studio/email.rb
331
333
  - lib/studio/email_smoke.rb
332
334
  - lib/studio/engine.rb
335
+ - lib/studio/environment_banner.rb
333
336
  - lib/studio/image_cache.rb
337
+ - lib/studio/link_resolution.rb
334
338
  - lib/studio/link_token.rb
335
339
  - lib/studio/mail_transport.rb
336
340
  - lib/studio/redis.rb
337
341
  - lib/studio/s3.rb
342
+ - lib/studio/sidebar_sections.rb
338
343
  - lib/studio/theme_resolver.rb
339
344
  - lib/studio/ui_primitives.rb
340
345
  - lib/studio/username_generator.rb
@@ -343,14 +348,14 @@ files:
343
348
  - lib/tasks/studio_ses.rake
344
349
  - studio-engine.gemspec
345
350
  - tailwind/studio.tailwind.config.js
346
- homepage: https://github.com/amcritchie/studio-engine
351
+ homepage: https://github.com/McRitchie-Studio/studio-engine
347
352
  licenses:
348
353
  - MIT
349
354
  metadata:
350
- homepage_uri: https://github.com/amcritchie/studio-engine
351
- source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
352
- bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
353
- changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
355
+ homepage_uri: https://github.com/McRitchie-Studio/studio-engine
356
+ source_code_uri: https://github.com/McRitchie-Studio/studio-engine/tree/main
357
+ bug_tracker_uri: https://github.com/McRitchie-Studio/studio-engine/issues
358
+ changelog_uri: https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md
354
359
  post_install_message:
355
360
  rdoc_options: []
356
361
  require_paths:
@@ -1,122 +0,0 @@
1
- # Unified create-or-login magic link.
2
- #
3
- # A magic link is a signed, short-lived, single-use token keyed on an EMAIL
4
- # (the user may not exist yet — clicking the link either logs them in or
5
- # creates the account). The token is a `message_verifier(token_name)` payload
6
- # carrying the email + a sanitized return_to + a random jti.
7
- #
8
- # Single-use is enforced with the jti: on `generate` we record the jti in
9
- # Rails.cache (Redis, cross-process); on `consume` we delete it and reject if
10
- # it was already gone (replay / second click). The signature already covers
11
- # tamper + expiry; the jti closes the replay gap.
12
- #
13
- # Token name + TTL come from Studio config (Studio.magic_link_token_name /
14
- # Studio.magic_link_ttl) so each app can tune them; the jti cache entry is
15
- # always given a few extra minutes so a still-valid token's jti is present.
16
- #
17
- # NOTE on test env: the test cache is :null_store, where writes/deletes are
18
- # no-ops and `delete` always returns false — enforcing single-use there would
19
- # reject every legitimate consume. So enforcement is skipped for non-tracking
20
- # stores; the service unit test injects a real MemoryStore to exercise it.
21
- #
22
- # Lifted into studio-engine (was turf-monster app/services/magic_link.rb).
23
- class MagicLink
24
- # Back-compat defaults. Behavior is driven by the `token_name` / `ttl` methods
25
- # (which read Studio config); these constants remain so existing consumer code
26
- # /tests referencing MagicLink::TTL keep working, and they equal the config
27
- # defaults.
28
- TOKEN_KEY = "magic_link_v1"
29
- TTL = 15.minutes
30
-
31
- class InvalidToken < StandardError; end
32
-
33
- Result = Struct.new(:email, :return_to, keyword_init: true)
34
-
35
- class << self
36
- # Test seam — defaults to Rails.cache. The service unit test sets this to
37
- # an ActiveSupport::Cache::MemoryStore to assert single-use, then resets it.
38
- attr_writer :cache
39
-
40
- def cache
41
- @cache || Rails.cache
42
- end
43
-
44
- def token_name
45
- Studio.magic_link_token_name
46
- end
47
-
48
- def ttl
49
- Studio.magic_link_ttl
50
- end
51
-
52
- # jti outlives the token so a valid token's jti is always still present.
53
- def jti_ttl
54
- ttl + 5.minutes
55
- end
56
-
57
- # Returns a signed token string. `return_to` is sanitized to a local path.
58
- # The MessageVerifier blob is standard base64 (can contain "/" and "+"),
59
- # which breaks the `%r{[^/]+}` route constraint once the payload is large
60
- # enough to emit a "/". Wrap it URL-safe so the token is always
61
- # [A-Za-z0-9_-]=, matching the route and surviving URL generation.
62
- def generate(email:, return_to: nil)
63
- normalized = normalize_email(email)
64
- jti = SecureRandom.hex(16)
65
- cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use?
66
- raw = verifier.generate(
67
- { email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 },
68
- expires_in: ttl
69
- )
70
- Base64.urlsafe_encode64(raw)
71
- end
72
-
73
- # Verifies signature + expiry + single-use. Returns a Result or raises
74
- # InvalidToken. Idempotency is NOT offered — a consumed token is dead.
75
- def consume(token)
76
- raw = Base64.urlsafe_decode64(token.to_s)
77
- payload = verifier.verify(raw).with_indifferent_access
78
- raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present?
79
-
80
- if enforce_single_use?
81
- # delete returns true only when the jti was still present
82
- raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti]))
83
- elsif !Rails.env.test?
84
- # Single-use is disabled (non-tracking cache). Expected in :null_store
85
- # dev; in any other env it means replay protection is silently OFF —
86
- # tokens are replayable for their TTL. Surface it loudly.
87
- Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry")
88
- end
89
-
90
- Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to]))
91
- rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
92
- # ArgumentError → malformed base64 (tampered/truncated token).
93
- raise InvalidToken, "invalid or expired link"
94
- end
95
-
96
- private
97
-
98
- def verifier
99
- Rails.application.message_verifier(token_name)
100
- end
101
-
102
- def jti_key(jti)
103
- "magic_link/jti/#{jti}"
104
- end
105
-
106
- def normalize_email(email)
107
- email.to_s.strip.downcase
108
- end
109
-
110
- # Only same-origin absolute paths survive; everything else (protocol-relative
111
- # "//evil", absolute URLs, blank) collapses to nil so callers fall back to a
112
- # default redirect.
113
- def sanitize_path(path)
114
- p = path.to_s
115
- p.start_with?("/") && !p.start_with?("//") ? p : nil
116
- end
117
-
118
- def enforce_single_use?
119
- !cache.is_a?(ActiveSupport::Cache::NullStore)
120
- end
121
- end
122
- end
@@ -1,2 +0,0 @@
1
- <%# Legacy /magic_link/:token interstitial. Shared body in studio/_confirm_interstitial. %>
2
- <%= render "studio/confirm_interstitial", consume_path: magic_link_consume_path(token: @token) %>