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
@@ -19,6 +19,17 @@
19
19
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
20
20
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
21
21
  <% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
22
+ <%# Out-of-the-box navigation (engine 0.30): when the host declares
23
+ Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
24
+ icon rails and renders the slide-out panels after the header. With no
25
+ sections (the default) all of this is skipped — the navbar is unchanged.
26
+ Preview renders skip it too: the /admin/navbar page renders this partial
27
+ repeatedly and the panels carry page-unique ids. %>
28
+ <% sidebar_active = !is_preview && respond_to?(:studio_sidebar?) && studio_sidebar? %>
29
+ <% if sidebar_active %>
30
+ <% sidebar_trigger_desktop = capture { render "components/link_sidebar_trigger", class_name: "hidden md:inline-flex" } %>
31
+ <% extra_icons_html = safe_join([extra_icons_html, sidebar_trigger_desktop].compact) %>
32
+ <% end %>
22
33
  <%
23
34
  words = Studio.app_name.split
24
35
  last_word = words.pop
@@ -31,9 +42,14 @@
31
42
  :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
32
43
  <style>
33
44
  .user-nav-col { width: 14rem; }
45
+ /* No balance content → no reserved column. The nav hugs its icons and
46
+ name instead of floating in the balance's empty seat. max-width steps
47
+ mirror the col's clamps: the item is flex-shrink-0, so without them a
48
+ long nowrap username sizes the column past a narrow viewport. */
49
+ .user-nav-fit { max-width: 14rem; }
34
50
  .nav-title { display: flex; gap: 0.25em; align-items: baseline; }
35
- @media (min-width: 400px) { .user-nav-col { width: 15rem; } }
36
- @media (min-width: 768px) { .user-nav-col { width: 20rem; } }
51
+ @media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
52
+ @media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
37
53
  @media (max-width: 767px) {
38
54
  .nav-title { font-size: 1.25rem !important; flex-direction: column; gap: 0; line-height: 1.15; transition: font-size 0.3s; }
39
55
  .nav-title span:first-child { margin-bottom: -4px; }
@@ -69,12 +85,15 @@
69
85
  </nav>
70
86
  </div>
71
87
  </div>
72
- <%# Right side: user nav (matches sidebar width) %>
73
- <div class="user-nav-col flex-shrink-0 pl-0 pr-4 md:px-4">
88
+ <%# Right side: user nav. The fixed column (matches sidebar width) exists
89
+ to seat a balance in the top row; with no balance content it shrinks
90
+ to fit. %>
91
+ <div class="<%= balance_html.present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
74
92
  <% if show_user %>
75
93
  <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, show_logout_link: show_logout_link %>
76
94
  <% else %>
77
95
  <div class="flex items-center justify-end gap-3">
96
+ <%= sidebar_trigger_desktop if sidebar_active %>
78
97
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
79
98
  <%= link_to "Log in", login_path, class: "btn btn-primary" %>
80
99
  </div>
@@ -84,8 +103,13 @@
84
103
  <%# Mobile sub-navbar: compact row hidden on desktop — apps should override to add links %>
85
104
  <div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
86
105
  <span class="ml-auto flex items-center gap-3">
87
- <%= render "components/admin_dropdown" %>
106
+ <%= render "components/link_sidebar_trigger" if sidebar_active %>
107
+ <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
88
108
  <%= render "components/theme_toggle_morph" %>
89
109
  </span>
90
110
  </div>
91
111
  </header>
112
+ <%# The panels mount OUTSIDE the header: the sticky header's backdrop-filter
113
+ creates a containing block, which would pin a fixed panel to the header
114
+ instead of the viewport. %>
115
+ <%= render "components/link_sidebar" if sidebar_active %>
@@ -15,7 +15,7 @@
15
15
  .navbar-preview.is-mobile .hidden.md\:flex { display: none !important; }
16
16
  .navbar-preview.is-mobile .hidden.md\:block { display: none !important; }
17
17
  .navbar-preview.is-mobile .flex.md\:hidden { display: flex !important; }
18
- .navbar-preview.is-mobile .user-nav-col { padding-left: 0 !important; padding-right: 1rem !important; }
18
+ .navbar-preview.is-mobile .user-nav-col, .navbar-preview.is-mobile .user-nav-fit { padding-left: 0 !important; padding-right: 1rem !important; }
19
19
  .navbar-preview.is-mobile .nav-title { flex-direction: column !important; gap: 0 !important; line-height: 1.15 !important; }
20
20
  .navbar-preview.is-mobile .nav-title span:first-child { margin-bottom: -4px !important; }
21
21
  .navbar-preview.is-mobile .nav-title span:last-child { font-size: 1.5rem !important; }
@@ -26,15 +26,18 @@
26
26
  .navbar-preview.bp-tiny .nav-title span:last-child { font-size: 1.3rem !important; }
27
27
  .navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
28
28
  .navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
29
+ .navbar-preview.bp-tiny .user-nav-fit { max-width: 14rem !important; }
29
30
  .navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
30
31
 
31
32
  /* Small (400-767px) */
32
33
  .navbar-preview.bp-small .nav-title { font-size: 1.25rem !important; }
33
34
  .navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
35
+ .navbar-preview.bp-small .user-nav-fit { max-width: 15rem !important; }
34
36
  .navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
35
37
 
36
38
  /* Desktop (768px+) */
37
39
  .navbar-preview.is-desktop .user-nav-col { width: 20rem !important; }
40
+ .navbar-preview.is-desktop .user-nav-fit { max-width: 20rem !important; }
38
41
  .navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
39
42
 
40
43
  /* Transitions for scrolled toggle */
@@ -1,9 +1,10 @@
1
1
  <%#
2
2
  Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
3
3
  the page auto-POSTs the CSRF-protected form to `consume_path` (the only place
4
- a single-use token is burned). Used by both MagicLinksController#confirm
5
- (consume_path: magic_link_consume_path) and Studio::LinksController#show
6
- (consume_path: link_consume_path). Rendered with layout false (full document).
4
+ a single-use token is burned). Rendered by Studio::LinksController#show
5
+ (consume_path: link_consume_path) the engine's only token door since 0.31.0
6
+ and by any app drawing its own token route. Rendered with layout false
7
+ (full document).
7
8
 
8
9
  Local: consume_path — the POST target that burns the token + signs in.
9
10
  %>
@@ -2,6 +2,8 @@
2
2
  label = local_assigns[:label]
3
3
  content = local_assigns[:content] || label
4
4
  href = local_assigns[:href]
5
+ # :link · :button · :status — a status chip carries the same chrome but is
6
+ # inert (no href, no click), for facts we can report but not navigate to.
5
7
  as = local_assigns.fetch(:as, href.present? ? :link : :button).to_sym
6
8
  method = local_assigns[:method]
7
9
  tooltip = local_assigns[:tooltip]
@@ -36,7 +38,7 @@
36
38
  "background:#{default_background}",
37
39
  "border:1px solid #{default_border}",
38
40
  "color:#{default_color}",
39
- "cursor:pointer",
41
+ "cursor:#{as == :status ? "default" : "pointer"}",
40
42
  "font:inherit",
41
43
  "font-weight:700",
42
44
  "text-decoration:none",
@@ -73,7 +75,13 @@
73
75
  end
74
76
  %>
75
77
 
76
- <% if as == :button %>
78
+ <%# `tag.span(hash) { }` would read the hash as CONTENT and drop every attribute
79
+ on the floor — the options must be splatted as keywords when a block is given. %>
80
+ <% if as == :status %>
81
+ <%= tag.span(**attrs.merge(role: "status", tabindex: "0")) do %>
82
+ <%= content %><%= tooltip_content if tooltip.present? %>
83
+ <% end %>
84
+ <% elsif as == :button %>
77
85
  <%= button_tag attrs.merge(type: "button") do %>
78
86
  <%= content %><%= tooltip_content if tooltip.present? %>
79
87
  <% end %>
@@ -1,4 +1,15 @@
1
- <% details = email_delivery_banner_details %>
1
+ <%
2
+ details = email_delivery_banner_details
3
+
4
+ # Link the inbox only where it actually answers. Studio::LocalEmailsController
5
+ # gates on the SAME call, so this can never advertise a 404: on QA (Rails
6
+ # production, remote requests) the button degrades to a non-link status chip
7
+ # that still reports the connector and whether mail is sending or captured.
8
+ inbox_reachable = Studio.local_inbox_reachable?(
9
+ request_local: (respond_to?(:request) && request ? request.local? : false)
10
+ )
11
+ tooltip = inbox_reachable ? details.fetch(:tooltip) : "#{details.fetch(:tooltip)} · Inbox: local only"
12
+ %>
2
13
 
3
14
  <% content = capture do %>
4
15
  <span>Email</span>
@@ -16,8 +27,16 @@
16
27
  <% end %>
17
28
  <% end %>
18
29
 
19
- <%= render "studio/banners/button",
20
- href: "/_studio/local_emails",
21
- label: "Email",
22
- content: content,
23
- tooltip: details.fetch(:tooltip) %>
30
+ <% if inbox_reachable %>
31
+ <%= render "studio/banners/button",
32
+ href: Studio::EnvironmentBanner::INBOX_PATH,
33
+ label: "Email",
34
+ content: content,
35
+ tooltip: tooltip %>
36
+ <% else %>
37
+ <%= render "studio/banners/button",
38
+ as: :status,
39
+ label: "Email",
40
+ content: content,
41
+ tooltip: tooltip %>
42
+ <% end %>
@@ -1,18 +1,43 @@
1
+ <%#
2
+ The ONE environment banner every Studio app renders. Host layouts render this
3
+ partial and nothing else — it decides FOR ITSELF whether to appear, what to
4
+ say, and whether the local inbox is linkable. See docs/NEW_APP_SETUP.md § 9.
5
+
6
+ Locals (all optional):
7
+ preview — true inside a navbar-preview render; suppresses the
8
+ banner so a preview copy can't duplicate live chrome.
9
+ environment_label — override the whole message (rare; prefer `extra`).
10
+ extra — extra message segments, joined with " · " (e.g. "Devnet").
11
+ devnet — renders a DEVNET chip beside the buttons.
12
+ %>
1
13
  <%
2
- environment_label = local_assigns.fetch(:environment_label, "#{Rails.env.capitalize} Environment")
3
- devnet = local_assigns.fetch(:devnet, false)
14
+ preview = local_assigns.fetch(:preview, false)
15
+ devnet = local_assigns.fetch(:devnet, false)
16
+ extra = Array(local_assigns.fetch(:extra, []))
17
+ qa_environment = Studio.qa_environment?
18
+
19
+ # On QA the devnet fact belongs in the message (the chip's contrast is built
20
+ # for the dev strip); everywhere else it stays a chip beside the buttons.
21
+ message_extra = devnet && qa_environment ? extra + ["Devnet"] : extra
22
+ show_devnet_chip = devnet && !qa_environment
23
+
24
+ environment_label = local_assigns.fetch(:environment_label) do
25
+ Studio.environment_banner_message(extra: message_extra)
26
+ end
4
27
  %>
5
28
 
6
- <% actions = capture do %>
7
- <% if devnet %>
8
- <span class="rounded" style="font-size:10px; font-weight:700; padding:1px 6px; background:rgba(0,0,0,0.2);">DEVNET</span>
29
+ <% if !preview && Studio.show_environment_banner? %>
30
+ <% actions = capture do %>
31
+ <% if show_devnet_chip %>
32
+ <span class="rounded" style="font-size:10px; font-weight:700; padding:1px 6px; background:rgba(0,0,0,0.2);">DEVNET</span>
33
+ <% end %>
34
+ <%= render "studio/banners/dev_mode_button" %>
35
+ <%= render "studio/banners/email_status_button" %>
9
36
  <% end %>
10
- <%= render "studio/banners/dev_mode_button" %>
11
- <%= render "studio/banners/email_status_button" %>
12
- <% end %>
13
37
 
14
- <%= render "studio/banners/app_banner",
15
- tone: :environment,
16
- density: :normal,
17
- message: environment_label,
18
- actions: actions %>
38
+ <%= render "studio/banners/app_banner",
39
+ tone: :environment,
40
+ density: :normal,
41
+ message: environment_label,
42
+ actions: actions %>
43
+ <% end %>
@@ -3,6 +3,16 @@
3
3
  # migration; each consumer app installs its own copy (the table is app-owned).
4
4
  class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
5
5
  def change
6
+ # No-op on an app that doesn't use ImageCache. `image_caches` is app-owned,
7
+ # so an app can install the engine's migrations without having that table —
8
+ # and unguarded, this raised and failed the whole `db:migrate`.
9
+ #
10
+ # Deleting the copy is NOT a workaround: install:migrations builds its
11
+ # skip-list from the files PRESENT, so a deleted copy is re-copied with a
12
+ # fresh timestamp on the next upgrade and fails again. The guard has to live
13
+ # here, in the migration, or it doesn't hold.
14
+ return unless table_exists?(:image_caches)
15
+
6
16
  change_column_null :image_caches, :owner_type, true
7
17
  change_column_null :image_caches, :owner_id, true
8
18
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The shared dev/QA environment banner's decisions, as pure Ruby.
5
+ #
6
+ # Every Studio app used to hand-roll its own yellow "<Env> Environment" strip,
7
+ # so no two agreed on when it appeared, what it said, or whether it linked the
8
+ # local email inbox. Those three rules live here and `studio/banners/_environment`
9
+ # renders them, so a host adopts the standard with one `render` call.
10
+ #
11
+ # Dependency-free on purpose: the unit suite requires this file directly, so
12
+ # the tests exercise the SHIPPED rules rather than a copy of them.
13
+ module EnvironmentBanner
14
+ QA_ENV_VAR = "QA_ENV"
15
+
16
+ # The engine's local email inbox (Studio::LocalEmailsController).
17
+ INBOX_PATH = "/_studio/local_emails"
18
+
19
+ module_function
20
+
21
+ # True for stable QA apps. They run Rails in production mode but are
22
+ # non-production REVIEW targets and must say so. The release conductor sets
23
+ # QA_ENV=true on every QA app (mcritchie-studio/config/qa_environments.yml).
24
+ def qa_environment?(env = ENV)
25
+ truthy?(env[QA_ENV_VAR])
26
+ end
27
+
28
+ # Show the banner in every environment EXCEPT real production. A QA app is
29
+ # Rails-production but not real production, so QA_ENV re-opens it there.
30
+ def show?(rails_env:, qa_environment: qa_environment?)
31
+ return true unless rails_env.to_s == "production"
32
+
33
+ qa_environment
34
+ end
35
+
36
+ # "Development Environment" · "QA Environment · Non-production", plus any
37
+ # app-supplied suffix ("Devnet", ...).
38
+ def message(rails_env:, qa_environment: qa_environment?, extra: [])
39
+ head = if qa_environment
40
+ ["QA Environment", "Non-production"]
41
+ else
42
+ ["#{rails_env.to_s.capitalize} Environment"]
43
+ end
44
+
45
+ (head + present_parts(extra)).join(" · ")
46
+ end
47
+
48
+ def present_parts(extra)
49
+ Array(extra).map(&:to_s).reject { |part| part.strip.empty? }
50
+ end
51
+
52
+ def truthy?(value)
53
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "link_token"
4
+
5
+ module Studio
6
+ # What a magic-link click should DO — the whole decision as one pure table,
7
+ # free of ActiveRecord and of the controller, so every cell is unit-testable.
8
+ #
9
+ # A click has three inputs: whether this caller BURNED the link (won the
10
+ # single-use race), whose email the link carries, and who is already signed
11
+ # in. Six cells fall out of that, and one invariant runs through them:
12
+ #
13
+ # **A dead link never touches the session.**
14
+ #
15
+ # Before this table, every dead token — used, expired, or unknown — ended at
16
+ # `redirect_to login_path, alert: "invalid or has expired"`, whatever session
17
+ # the visitor was holding. So clicking your own link a second time dumped you
18
+ # on a sign-in page, which reads as being logged out. It never was: the cookie
19
+ # survived, but the destination said otherwise. Now a dead link is at worst a
20
+ # notice, and at best silent.
21
+ #
22
+ # The table (rows = link state, columns = who is signed in):
23
+ #
24
+ # | nobody | the link's own user | somebody else
25
+ # -------------+-----------------+---------------------+------------------
26
+ # live (burned)| :authenticate | :continue | :authenticate
27
+ # used/expired | :dead → login | :dead → return_to | :dead → home
28
+ # unknown token| :dead → login | — | :dead → home
29
+ #
30
+ # `:continue` is the cell the operator asked for by name: a second click on
31
+ # your own still-live link must be "no material difference — just a redirect".
32
+ # It burns the token (so nobody replays it later) and deliberately does NOT
33
+ # re-authenticate, because a host that rotates the session on sign-in — as
34
+ # turf-monster does — would otherwise charge a re-click the price of every
35
+ # scrap of session state the visitor had built up.
36
+ module LinkResolution
37
+ # How the click found the link.
38
+ # :claimed — this caller burned a live link (the only status that authenticates)
39
+ # :used — the row exists and was already consumed, or lost the burn race
40
+ # :expired — the row exists and is past expires_at
41
+ # :unknown — no row for this token, or the token is not a magic link
42
+ STATUSES = %i[claimed used expired unknown].freeze
43
+
44
+ # :authenticate — establish a session for the link's email (sign in or sign up)
45
+ # :continue — the viewer already IS the link's user: keep the session as
46
+ # it stands and land them on the link's destination
47
+ # :dead — do not touch the session at all
48
+ ACTIONS = %i[authenticate continue dead].freeze
49
+
50
+ # Where the click lands.
51
+ # :return_to — the link's own destination (falling back to home)
52
+ # :home — the app root; used when the link belongs to someone else,
53
+ # so its destination is not ours to follow
54
+ # :login — the sign-in page; only ever for a visitor with no session
55
+ DESTINATIONS = %i[return_to home login].freeze
56
+
57
+ Outcome = Struct.new(:action, :destination, :message, :level, keyword_init: true) do
58
+ def authenticate?
59
+ action == :authenticate
60
+ end
61
+
62
+ def continue?
63
+ action == :continue
64
+ end
65
+
66
+ def dead?
67
+ action == :dead
68
+ end
69
+
70
+ # A silent outcome shows the visitor nothing — the "no material
71
+ # difference" re-click.
72
+ def silent?
73
+ message.nil?
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ # @param status [Symbol] one of STATUSES
80
+ # @param link_email [String, nil] the email the link signs in (nil = unknown token)
81
+ # @param session_email [String, nil] the currently signed-in user's email
82
+ # @return [Outcome]
83
+ def call(status:, link_email: nil, session_email: nil)
84
+ raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
85
+
86
+ own = own_link?(link_email, session_email)
87
+
88
+ if status == :claimed
89
+ return Outcome.new(action: :continue, destination: :return_to) if own
90
+
91
+ # Nobody signed in, or somebody else signed in: both establish a session
92
+ # for the link's email. The second case is the deliberate account switch
93
+ # — a live link is proof of ownership, so it outranks the open session.
94
+ return Outcome.new(action: :authenticate, destination: :return_to)
95
+ end
96
+
97
+ # Dead from here down: no branch below may write to the session.
98
+ return Outcome.new(action: :dead, destination: :return_to) if own
99
+
100
+ if Studio::LinkToken.normalize_email(session_email).empty?
101
+ Outcome.new(action: :dead, destination: :login, level: :alert,
102
+ message: dead_message(status: status, link_email: link_email))
103
+ else
104
+ Outcome.new(action: :dead, destination: :home, level: :notice,
105
+ message: dead_message(status: status, link_email: link_email,
106
+ session_email: session_email))
107
+ end
108
+ end
109
+
110
+ # Same email, both sides present. A blank on either side is never a match —
111
+ # an unknown token (no email) must not read as "your own link" just because
112
+ # nobody is signed in.
113
+ def own_link?(link_email, session_email)
114
+ link = Studio::LinkToken.normalize_email(link_email)
115
+ seat = Studio::LinkToken.normalize_email(session_email)
116
+ !link.empty? && link == seat
117
+ end
118
+
119
+ # The notice a dead link earns. It names the address the link was for and
120
+ # why it failed — the detail that turns "something went wrong" into a
121
+ # decision the reader can act on — and, when a session is open, says so
122
+ # plainly, because the whole point is that nothing was lost.
123
+ def dead_message(status:, link_email: nil, session_email: nil)
124
+ addressee = Studio::LinkToken.normalize_email(link_email)
125
+ who = addressee.empty? ? "" : " for #{addressee}"
126
+ why = case status
127
+ when :expired then "has expired"
128
+ when :used then "was already used"
129
+ else "is no longer valid"
130
+ end
131
+
132
+ base = "That sign-in link#{who} #{why}."
133
+ seat = Studio::LinkToken.normalize_email(session_email)
134
+ return "#{base} Request a fresh one below." if seat.empty?
135
+
136
+ "#{base} You are still signed in as #{seat} — request a fresh link to switch accounts."
137
+ end
138
+ end
139
+ end
@@ -18,10 +18,19 @@ module Studio
18
18
  # they are deliberately NOT single-use.
19
19
  SINGLE_USE_KINDS = %w[magic_link].freeze
20
20
 
21
- # 96 bits of entropy~16 URL-safe chars (e.g. "PP-PDbEj5V3-aNh4"). Short
22
- # enough to keep the URL clean, far too large to brute-force — especially
23
- # for single-use, expiring magic links. Matches turf-monster's proven format.
24
- TOKEN_BYTES = 12
21
+ # THE HOUSE TOKEN STANDARD: 12 random bytes exactly 16 URL-safe
22
+ # characters (e.g. "PP-PDbEj5V3-aNh4"). 96 bits of entropy short enough
23
+ # that the whole link fits on one line of an email, far too large to
24
+ # brute-force, especially for a single-use token that expires in minutes.
25
+ #
26
+ # 16 sits mid-range in the house bound of 10-20 characters (TOKEN_LENGTH_
27
+ # BOUNDS), which is the number a reader should sanity-check a link against.
28
+ # urlsafe_base64 emits 4 characters per 3 bytes with no padding, so the
29
+ # length is exact, not approximate — every token is the same width.
30
+ TOKEN_BYTES = 12
31
+ TOKEN_LENGTH = 16
32
+ TOKEN_LENGTH_BOUNDS = (10..20).freeze
33
+ TOKEN_FORMAT = /\A[A-Za-z0-9_-]+\z/
25
34
 
26
35
  module_function
27
36
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves Studio.sidebar_sections — the host's declared link-sidebar data —
4
+ # for a given view context. Pure Ruby (no Rails dependency) so the unit suite
5
+ # exercises the resolution rules without booting the dummy app.
6
+ #
7
+ # Declared sections may be a static Array or a callable (receives the view
8
+ # context) for dynamic data: route helpers, logged_in? walls, model-backed
9
+ # link lists. Each section normalizes to symbol keys:
10
+ #
11
+ # { title: "Site", admin: true, links: [
12
+ # { label: "Dashboard", href: "/admin", emoji: "📊",
13
+ # hover_emoji: "🔬", desc: "Users + logs", target: "_blank" } ] }
14
+ #
15
+ # Sections flagged admin: true resolve only for admin? viewers, so the
16
+ # trigger and panel stay invisible to everyone else even when the host
17
+ # declares nothing but admin links.
18
+ module Studio
19
+ module SidebarSections
20
+ module_function
21
+
22
+ def resolve(declared, view)
23
+ sections = declared.respond_to?(:call) ? declared.call(view) : declared
24
+ admin = view.respond_to?(:admin?) && view.admin?
25
+ Array(sections).map { |section| symbolize(section) }
26
+ .reject { |section| section[:admin] && !admin }
27
+ .map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
28
+ end
29
+
30
+ def symbolize(hash)
31
+ hash.to_h.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
32
+ end
33
+ end
34
+ end
@@ -48,7 +48,8 @@ module Studio
48
48
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
49
49
  "--color-success" => colors[:success] || "#4BAF50",
50
50
  "--color-warning" => colors[:warning] || "#FF7C47",
51
- "--color-danger" => colors[:danger] || "#EF4444"
51
+ "--color-danger" => colors[:danger] || "#EF4444",
52
+ "--color-accent" => colors[:accent] || "#F72585"
52
53
  }
53
54
  end
54
55
 
@@ -92,7 +93,8 @@ module Studio
92
93
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
93
94
  "--color-success" => colors[:success] || "#4BAF50",
94
95
  "--color-warning" => colors[:warning] || "#FF7C47",
95
- "--color-danger" => colors[:danger] || "#EF4444"
96
+ "--color-danger" => colors[:danger] || "#EF4444",
97
+ "--color-accent" => colors[:accent] || "#F72585"
96
98
  }
97
99
  end
98
100
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.29.1"
2
+ VERSION = "0.31.0"
3
3
  end