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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +254 -0
- data/README.md +5 -4
- data/app/assets/tailwind/studio_engine/engine.css +18 -0
- data/app/controllers/concerns/studio/error_handling.rb +7 -2
- data/app/controllers/concerns/studio/link_consumption.rb +131 -11
- data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
- data/app/controllers/magic_links_controller.rb +13 -40
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/controllers/studio/links_controller.rb +17 -19
- data/app/controllers/studio/local_reviews_controller.rb +4 -4
- data/app/helpers/studio_sidebar_helper.rb +22 -0
- data/app/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/components/_link_sidebar.html.erb +179 -0
- data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
- data/app/views/components/_sidebar_panel.html.erb +63 -0
- data/app/views/components/_user_nav.html.erb +13 -7
- data/app/views/layouts/_navbar.html.erb +29 -5
- data/app/views/navbar/show.html.erb +4 -1
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/app/views/studio/banners/_button.html.erb +10 -2
- data/app/views/studio/banners/_email_status_button.html.erb +25 -6
- data/app/views/studio/banners/_environment.html.erb +38 -13
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/environment_banner.rb +56 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/sidebar_sections.rb +34 -0
- data/lib/studio/theme_resolver.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +108 -28
- data/studio-engine.gemspec +5 -5
- metadata +14 -9
- data/app/services/magic_link.rb +0 -122
- 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
|
-
|
|
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/
|
|
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).
|
|
5
|
-
(consume_path:
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
<%
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
3
|
-
devnet
|
|
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
|
-
<%
|
|
7
|
-
<%
|
|
8
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
data/lib/studio/link_token.rb
CHANGED
|
@@ -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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
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
|
data/lib/studio/version.rb
CHANGED