studio-engine 0.117.0 → 0.119.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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Studio
6
+ # The branded loader (docs/LOADER.md), as view helpers. Every one draws the
7
+ # app's own icon (Studio.loader_icon) in its theme colours, in the style the
8
+ # app declared with Studio.loader; there is no per-app markup.
9
+ #
10
+ # <%= studio_loader %> 96px, the app's style
11
+ # <%= studio_loader size: 20, label: "Saving" %> inline, beside text
12
+ # <%= studio_loader style: :liquid, percent: 40 %> a fill that follows a percent
13
+ # <%= studio_loader_pending %> inside a submit button
14
+ # <%= studio_loader_overlay %> the page-load overlay (layout, once)
15
+ #
16
+ # Prefixed names for the reason Studio::GeoHelper gives: every helper module is
17
+ # included into every view.
18
+ module LoaderHelper
19
+ # Bead layers, coolest first: [dash length of 100, stroke width in a 264-unit
20
+ # icon, opacity]. The colours are CSS (--studio-loader-bead-1..6), so a theme
21
+ # can retune them. The last three glow.
22
+ LOADER_BEAD_LAYERS = [[46, 9, 0.5], [30, 9, 0.75], [16, 9, 0.95], [7, 9.5, 1], [2.6, 10, 1], [0.3, 17, 1]].freeze
23
+ LOADER_BEAD_UNIT = 264.3
24
+
25
+ def studio_loader(size: 96, style: nil, icon: nil, art: nil, label: "Loading", percent: nil,
26
+ tone: nil, pending: false, **html)
27
+ config = Studio.loader
28
+ style = style ? Studio::Loader.style!(style) : config[:style]
29
+ art_data = nil
30
+ if Studio::Loader.art_style?(style)
31
+ art_data = Studio::Loader.art(art || config[:art], style)
32
+ style = Studio::Loader::DEFAULT_STYLE if art_data.nil?
33
+ end
34
+
35
+ render "studio/loader/loader",
36
+ style: style,
37
+ size: studio_loader_size(size),
38
+ icon: icon || Studio.loader_icon,
39
+ art: art_data,
40
+ label: label,
41
+ percent: percent.nil? ? nil : percent.to_f.clamp(0, 100).round(1),
42
+ mono: (tone || (pending ? :mono : nil)).to_s == "mono",
43
+ pending: pending,
44
+ uid: "studio-loader-#{SecureRandom.hex(4)}",
45
+ html: html
46
+ end
47
+
48
+ # The small loader a submit button carries. Hidden until the button, or the
49
+ # form it submits, is aria-busy (Turbo marks the form; studio/loader marks
50
+ # the button for a form Turbo does not drive). Drawn in the button's own
51
+ # text colour, so it reads on any fill.
52
+ def studio_loader_pending(size: 18, style: :breathe, **options)
53
+ studio_loader(size: size, style: style, pending: true, **options)
54
+ end
55
+
56
+ # The full-page overlay: once, in the layout's body. studio/loader shows it
57
+ # when a visit the user started (never a background replace) or a marked
58
+ # form outlasts `delay` ms, and window.StudioLoader.show() / hide() drive it
59
+ # for any other long wait. A submit Turbo does not drive clears its overlay
60
+ # and busy button after `max_wait` ms, so a response that became a download
61
+ # never leaves them up; a Turbo visit is never capped.
62
+ # Any studio_loader option (style:, icon:, art:) passes through.
63
+ def studio_loader_overlay(label: "Loading", delay: 350, max_wait: 15_000, size: 96, **options)
64
+ render "studio/loader/overlay", label: label, delay: delay.to_i, max_wait: max_wait.to_i, size: size, options: options
65
+ end
66
+
67
+ def studio_loader_size(size)
68
+ size.is_a?(Numeric) ? "#{size}px" : size.to_s
69
+ end
70
+
71
+ # The stroke layers a weld bead draws over the art's outline path.
72
+ def studio_loader_bead_layers(art)
73
+ unit = art[:width] / LOADER_BEAD_UNIT
74
+ LOADER_BEAD_LAYERS.each_with_index.map do |(dash, width, opacity), index|
75
+ { n: index + 1, dash: dash, width: (width * unit).round(2), opacity: opacity, glow: index >= 3 }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -10,6 +10,6 @@ module StudioThemeHelper
10
10
  Studio::ThemeResolver.new(colors).to_css
11
11
  end
12
12
 
13
- tag.style("#{css}\n#{Studio::UiPrimitives.css}".html_safe, nonce: content_security_policy_nonce)
13
+ tag.style("#{css}\n#{Studio::TypeScale.css}\n#{Studio::UiPrimitives.css}".html_safe, nonce: content_security_policy_nonce)
14
14
  end
15
15
  end
@@ -17,9 +17,11 @@ import LinkSidebarController from "studio/controllers/link_sidebar_controller"
17
17
  import IdentityMiniController from "studio/controllers/identity_mini_controller"
18
18
  import { startPinnedStack } from "studio/pinned_stack"
19
19
  import { installAlpineShims } from "studio/alpine_shims"
20
+ import { installStudioLoader } from "studio/loader"
20
21
 
21
22
  installAlpineShims()
22
23
  startPinnedStack()
24
+ installStudioLoader()
23
25
 
24
26
  application.register("nav-collapse", NavCollapseController)
25
27
  application.register("modal-host", ModalHostController)
@@ -0,0 +1,214 @@
1
+ // studio/loader: drives the branded loader's two live states (docs/LOADER.md).
2
+ // The motion itself is CSS (engine-motion.css, "Branded loader"); this module
3
+ // only decides WHEN the page-load overlay shows and marks busy buttons.
4
+ //
5
+ // the overlay [data-studio-loader-overlay], rendered once by
6
+ // studio_loader_overlay. Shown when a visit the USER started (a
7
+ // Turbo advance or restore; never a replace, a refresh, a frame
8
+ // or a prefetch), or a submit of a form marked
9
+ // data-studio-loader-overlay, outlasts the overlay's data-delay
10
+ // ms; hidden when the page arrives. Its role=status label is
11
+ // announced: the overlay never marks itself aria-busy.
12
+ // pending a button holding studio_loader_pending. Turbo marks a
13
+ // submitting form aria-busy itself; for a form Turbo does not
14
+ // drive, the submitter is marked here, and cleared on pagehide
15
+ // and on pageshow (a bfcache restore).
16
+ // the cap a submit Turbo does not drive clears its overlay and button
17
+ // after data-max-wait ms (MAX_WAIT_MS by default): a response that
18
+ // turned into a download replaces no page, so nothing else
19
+ // would. A Turbo visit is never capped; a non-HTML response
20
+ // clears at once, on turbo:reload.
21
+ // the API window.StudioLoader.show(caption) / hide() for any other long
22
+ // wait, and setPercent(el, n) for a loader drawn with percent:.
23
+ //
24
+ // The logic is exported for test/javascript/loader.test.mjs.
25
+
26
+ export var DEFAULT_DELAY_MS = 350
27
+ export var MAX_WAIT_MS = 15000
28
+
29
+ // Only a visit the user started earns the overlay: a click or Turbo.visit
30
+ // (advance) and Back or Forward (restore). A replace is a background refresh
31
+ // (a poll, Turbo 8's page refresh); frames and prefetches never fire turbo:visit.
32
+ export function userVisit(event) {
33
+ var action = event && event.detail && event.detail.action
34
+ return action === 'advance' || action === 'restore'
35
+ }
36
+
37
+ export function overlayDelay(overlay) {
38
+ var value = overlay ? parseInt(overlay.getAttribute('data-delay'), 10) : NaN
39
+ return value >= 0 ? value : DEFAULT_DELAY_MS
40
+ }
41
+
42
+ // The percent a caller handed setPercent, clamped to a number in 0..100.
43
+ export function clampPercent(value) {
44
+ var n = Number(value)
45
+ if (!isFinite(n)) return 0
46
+ return Math.min(100, Math.max(0, n))
47
+ }
48
+
49
+ export function maxWait(overlay) {
50
+ var value = overlay ? parseInt(overlay.getAttribute('data-max-wait'), 10) : NaN
51
+ return value > 0 ? value : MAX_WAIT_MS
52
+ }
53
+
54
+ function findOverlay(doc) {
55
+ return doc.querySelector('[data-studio-loader-overlay]')
56
+ }
57
+
58
+ var pendingTimer = null
59
+ var capTimer = null
60
+ var autoShown = false
61
+
62
+ // The overlay's status is a live region; rewriting its label after the overlay
63
+ // unhides makes a screen reader say it, where text already present when a
64
+ // region appears is often skipped. The label is kept in data-studio-loader-label,
65
+ // so a second show inside the gap restores it rather than the emptied text.
66
+ function announce(overlay, doc) {
67
+ var label = overlay.querySelector('[role="status"] .sr-only')
68
+ if (!label) return
69
+ var text = label.getAttribute('data-studio-loader-label')
70
+ if (text === null) { text = label.textContent; label.setAttribute('data-studio-loader-label', text) }
71
+ label.textContent = ''
72
+ var win = doc.defaultView
73
+ ;(win && win.setTimeout ? win.setTimeout.bind(win) : setTimeout)(function () { label.textContent = text }, 50)
74
+ }
75
+
76
+ export function show(caption, doc) {
77
+ doc = doc || document
78
+ clearTimeout(pendingTimer)
79
+ pendingTimer = null
80
+ autoShown = false
81
+ var overlay = findOverlay(doc)
82
+ if (!overlay) return false
83
+ var line = overlay.querySelector('[data-studio-loader-caption]')
84
+ if (line) line.textContent = caption || ''
85
+ overlay.hidden = false
86
+ announce(overlay, doc)
87
+ // One frame hidden=false at opacity 0, then fade in.
88
+ var raf = (doc.defaultView && doc.defaultView.requestAnimationFrame) || function (fn) { return setTimeout(fn, 16) }
89
+ raf(function () { if (!overlay.hidden) overlay.setAttribute('data-state', 'shown') })
90
+ return true
91
+ }
92
+
93
+ export function hide(doc) {
94
+ doc = doc || document
95
+ clearTimeout(pendingTimer)
96
+ pendingTimer = null
97
+ var overlay = findOverlay(doc)
98
+ if (!overlay) return false
99
+ overlay.removeAttribute('data-state')
100
+ overlay.hidden = true
101
+ return true
102
+ }
103
+
104
+ // Clear the buttons studio/loader marked busy (never one a page marked itself).
105
+ export function clearBusy(doc) {
106
+ doc = doc || document
107
+ clearTimeout(capTimer)
108
+ capTimer = null
109
+ var marked = doc.querySelectorAll('[data-studio-loader-busy]')
110
+ for (var i = 0; i < marked.length; i++) {
111
+ marked[i].removeAttribute('aria-busy')
112
+ marked[i].removeAttribute('data-studio-loader-busy')
113
+ }
114
+ }
115
+
116
+ // Hide the overlay and clear every busy button: the page is ours again.
117
+ export function settle(doc) {
118
+ doc = doc || document
119
+ hide(doc)
120
+ clearBusy(doc)
121
+ }
122
+
123
+ // The cap never hides an overlay a caller showed through StudioLoader.show().
124
+ function armCap(doc) {
125
+ clearTimeout(capTimer)
126
+ capTimer = setTimeout(function () {
127
+ var shownByUs = autoShown
128
+ clearBusy(doc)
129
+ if (shownByUs) hide(doc)
130
+ }, maxWait(findOverlay(doc)))
131
+ }
132
+
133
+ // Show after the overlay's delay, so a fast load never flashes it. Uncapped: a
134
+ // slow Turbo visit keeps its overlay until the page arrives.
135
+ export function showSoon(caption, doc) {
136
+ doc = doc || document
137
+ var overlay = findOverlay(doc)
138
+ if (!overlay || !overlay.hidden) return
139
+ clearTimeout(pendingTimer)
140
+ pendingTimer = setTimeout(function () { show(caption, doc); autoShown = true }, overlayDelay(overlay))
141
+ }
142
+
143
+ export function markBusy(button, doc) {
144
+ doc = doc || document
145
+ button.setAttribute('aria-busy', 'true')
146
+ button.setAttribute('data-studio-loader-busy', '')
147
+ armCap(doc)
148
+ }
149
+
150
+ export function setPercent(el, value) {
151
+ if (!el) return
152
+ var loader = el.classList && el.classList.contains('studio-loader') ? el : el.querySelector('.studio-loader')
153
+ if (!loader) return
154
+ loader.setAttribute('data-loader-percent', '')
155
+ loader.style.setProperty('--studio-loader-percent', String(clampPercent(value)))
156
+ }
157
+
158
+ // A submitter that carries a pending loader.
159
+ export function pendingSubmitter(event) {
160
+ var submitter = event && (event.submitter || (event.detail && event.detail.formSubmission && event.detail.formSubmission.submitter))
161
+ if (!submitter || !submitter.querySelector) return null
162
+ return submitter.querySelector('.studio-loader[data-loader-pending]') ? submitter : null
163
+ }
164
+
165
+ var installed = false
166
+ export function installStudioLoader(doc) {
167
+ doc = doc || document
168
+ if (installed) return
169
+ installed = true
170
+
171
+ doc.addEventListener('turbo:visit', function (event) { if (userVisit(event)) showSoon('', doc) })
172
+ // turbo:reload: the response was not HTML (a download), or the visit failed,
173
+ // and Turbo hands the URL to the browser; no page may ever replace this one.
174
+ ;['turbo:load', 'turbo:render', 'turbo:before-cache', 'turbo:fetch-request-error', 'turbo:frame-load', 'turbo:reload'].forEach(function (name) {
175
+ doc.addEventListener(name, function () { hide(doc) })
176
+ })
177
+ // Leaving clears the page before the bfcache keeps it; a restore clears it
178
+ // again, in case it was kept some other way.
179
+ if (doc.defaultView) {
180
+ doc.defaultView.addEventListener('pagehide', function () { settle(doc) })
181
+ doc.defaultView.addEventListener('pageshow', function (event) { if (event.persisted) settle(doc) })
182
+ }
183
+
184
+ doc.addEventListener('turbo:submit-start', function (event) {
185
+ var form = event.target
186
+ if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) {
187
+ showSoon(form.getAttribute('data-studio-loader-overlay') || '', doc)
188
+ }
189
+ })
190
+ doc.addEventListener('turbo:submit-end', function (event) {
191
+ var form = event.target
192
+ if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) hide(doc)
193
+ })
194
+
195
+ // A form Turbo does not drive navigates away on its own; mark the button and,
196
+ // when asked, the overlay, until the next page replaces this one.
197
+ doc.addEventListener('submit', function (event) {
198
+ var form = event.target
199
+ if (event.defaultPrevented || !form || !form.getAttribute) return
200
+ var win = doc.defaultView
201
+ if (win && win.Turbo && form.getAttribute('data-turbo') !== 'false') return
202
+ var button = pendingSubmitter(event)
203
+ if (button) markBusy(button, doc)
204
+ if (form.hasAttribute('data-studio-loader-overlay')) { showSoon(form.getAttribute('data-studio-loader-overlay') || '', doc); armCap(doc) }
205
+ })
206
+
207
+ if (doc.defaultView && !doc.defaultView.StudioLoader) {
208
+ doc.defaultView.StudioLoader = {
209
+ show: function (caption) { return show(caption, doc) },
210
+ hide: function () { return hide(doc) },
211
+ setPercent: setPercent
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # "Request Google setup", sent to the team when someone presses it in the
5
+ # Google modal of an app whose OAuth client is not configured yet
6
+ # (Studio::GoogleSetupRequestsController, Studio::GoogleSignIn). Namespaced so
7
+ # a host's own mailers cannot shadow it; plain strings in, so an outbox row
8
+ # stores nothing but text.
9
+ class GoogleSetupMailer < ApplicationMailer
10
+ layout false
11
+
12
+ # The shared production OAuth client every app's callback is added to, and
13
+ # who adds it. Facts for the reader of the email, not configuration.
14
+ OAUTH_PROJECT = "enduring-maxim-491202-t6"
15
+
16
+ # host and redirect_uri come from the app's CONFIGURED canonical host
17
+ # (Studio::GoogleSignIn.canonical_host), never from the request, and are
18
+ # blank when the app names none: the email then says to set one.
19
+ def setup_requested(app_name, host, redirect_uri, to)
20
+ @app_name = app_name
21
+ @host = host.to_s.strip.presence
22
+ @redirect_uri = redirect_uri.to_s.strip.presence
23
+ @oauth_project = OAUTH_PROJECT
24
+ subject = "Google sign-in setup requested: #{app_name}"
25
+ subject += " (#{@host})" if @host
26
+ mail(to: to, subject: subject)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Studio
6
+ # The engine's default Google find-or-create (docs/USER_CONTRACT.md, "Google
7
+ # sign-in"), so an app signs people in with Google before it writes a line of
8
+ # its own User code.
9
+ #
10
+ # OmniauthCallbacksController calls Studio::OmniauthUser.resolve(User, ...),
11
+ # which uses the host's own User.from_omniauth when the class answers it and
12
+ # this module's find_or_create otherwise. A host that wants the default under
13
+ # its own name includes the concern; a host that defines `def
14
+ # self.from_omniauth` in the class body overrides it either way, because a
15
+ # method on the class itself is found before one from an extended module.
16
+ #
17
+ # The default, in order:
18
+ #
19
+ # 1. the account already holding this Google id (provider + uid);
20
+ # 2. else the account holding the address, compared case-insensitively, but
21
+ # only when Google verified the address: an unverified match answers
22
+ # :email_not_verified, since it proves nothing about who holds the
23
+ # mailbox;
24
+ # 3. else a new account, address downcased.
25
+ #
26
+ # THE SLUG COLLISION (Carl, 2026-10-29, on voicebox). The documented example
27
+ # created a second "Mia" whose name_slug was "mia" again; the unique slug index
28
+ # raised RecordNotUnique, the rescue looked for the address and the uid, found
29
+ # nobody, and sign-in failed. So the create checks the slug first, and a slug
30
+ # that is blank or already held becomes user-<id> (settled by Sluggable after
31
+ # the insert). A collision that survives that, with nobody to find, is logged
32
+ # with ErrorLog.capture! and answers nil, which the controller turns into a
33
+ # "use another method" redirect rather than a 500.
34
+ module OmniauthUser
35
+ extend ActiveSupport::Concern
36
+
37
+ class_methods do
38
+ def from_omniauth(auth, email_verified: false)
39
+ Studio::OmniauthUser.find_or_create(self, auth, email_verified: email_verified)
40
+ end
41
+ end
42
+
43
+ class << self
44
+ def resolve(model, auth, email_verified: false)
45
+ if model.respond_to?(:from_omniauth)
46
+ model.from_omniauth(auth, email_verified: email_verified)
47
+ else
48
+ find_or_create(model, auth, email_verified: email_verified)
49
+ end
50
+ end
51
+
52
+ def find_or_create(model, auth, email_verified: false)
53
+ provider = read(auth, "provider")
54
+ uid = read(auth, "uid")
55
+ return nil if provider.nil? || uid.nil?
56
+
57
+ user = model.find_by(provider: provider, uid: uid)
58
+ return user if user
59
+
60
+ email = normalize_email(read(auth, "info", "email"))
61
+ if email && (existing = find_by_email(model, email))
62
+ return :email_not_verified unless email_verified
63
+
64
+ existing.update!(provider: provider, uid: uid)
65
+ return existing
66
+ end
67
+
68
+ attributes = { provider: provider, uid: uid, email: email, name: read(auth, "info", "name") }
69
+ create_with_free_slug(model, attributes, email_verified: email_verified)
70
+ end
71
+
72
+ def normalize_email(value)
73
+ email = value.to_s.strip.downcase
74
+ email.empty? ? nil : email
75
+ end
76
+
77
+ def find_by_email(model, email)
78
+ return nil unless column?(model, "email")
79
+
80
+ model.where(model.arel_table[:email].lower.eq(email)).first
81
+ end
82
+
83
+ private
84
+
85
+ def create_with_free_slug(model, attributes, email_verified: false)
86
+ assignable = attributes.select { |key, value| !value.nil? && column?(model, key.to_s) }
87
+ last_error = nil
88
+
89
+ [false, true].each do |force_fallback|
90
+ record = model.new(assignable)
91
+ install_fallback_slug(record) if force_fallback || slug_needs_fallback?(model, record)
92
+ begin
93
+ model.transaction(requires_new: true) { record.save! }
94
+ return record
95
+ rescue ActiveRecord::RecordNotUnique => e
96
+ last_error = e
97
+ # A concurrent sign-in made the row first: that row is the answer.
98
+ # The Google id always identifies it; the address only when Google
99
+ # verified it, the same rule as step 2 above. An unverified address
100
+ # that collides with someone else's row hands that row to nobody.
101
+ found = model.find_by(provider: assignable[:provider], uid: assignable[:uid])
102
+ found ||= find_by_email(model, assignable[:email]) if email_verified && assignable[:email]
103
+ return found if found
104
+ end
105
+ end
106
+
107
+ log_unmatched_collision(last_error)
108
+ nil
109
+ end
110
+
111
+ def slug_needs_fallback?(model, record)
112
+ return false unless column?(model, "slug") && record.respond_to?(:name_slug, true)
113
+
114
+ candidate = begin
115
+ record.send(:name_slug).to_s
116
+ rescue StandardError
117
+ ""
118
+ end
119
+ candidate.strip.empty? || model.unscoped.where(slug: candidate).exists?
120
+ end
121
+
122
+ # user-<token> before the insert, user-<id> once the id exists: Sluggable
123
+ # writes the first in before_save and settles the second after_create.
124
+ def install_fallback_slug(record)
125
+ token = SecureRandom.hex(4)
126
+ record.define_singleton_method(:name_slug) { id ? "user-#{id}" : "user-#{token}" }
127
+ end
128
+
129
+ def log_unmatched_collision(error)
130
+ return unless error
131
+
132
+ Rails.logger.warn("[omniauth] Google sign-in collided with nobody to find: #{error.class}: #{error.message.to_s[0, 300]}")
133
+ ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
134
+ rescue StandardError => e
135
+ Rails.logger.warn("[omniauth] could not log the collision: #{e.class}")
136
+ end
137
+
138
+ def column?(model, name)
139
+ model.respond_to?(:attribute_names) && model.attribute_names.include?(name)
140
+ end
141
+
142
+ def read(auth, *path)
143
+ value = path.reduce(auth) do |node, key|
144
+ break nil if node.nil?
145
+
146
+ node.respond_to?(:[]) ? (node[key] || (node[key.to_sym] if node.is_a?(Hash))) : nil
147
+ end
148
+ string = value.to_s.strip
149
+ string.empty? ? nil : string
150
+ end
151
+ end
152
+ end
153
+ end
@@ -87,11 +87,9 @@
87
87
  </div>
88
88
  <% end %>
89
89
 
90
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
- class: "btn btn-google btn-lg w-full gap-3" do %>
92
- <%= render "components/google_logo" %>
93
- Sign up with Google
94
- <% end %>
90
+ <%# A POST to the strategy once the app has its OAuth client, the setup
91
+ modal until then (studio/google_sign_in/_button). %>
92
+ <%= render "studio/google_sign_in/button", label: "Sign up with Google" %>
95
93
  <% end %>
96
94
 
97
95
  <p class="text-center text-secondary text-sm mt-6">
@@ -87,11 +87,9 @@
87
87
  </div>
88
88
  <% end %>
89
89
 
90
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
- class: "btn btn-google btn-lg w-full gap-3" do %>
92
- <%= render "components/google_logo" %>
93
- Sign in with Google
94
- <% end %>
90
+ <%# A POST to the strategy once the app has its OAuth client, the setup
91
+ modal until then (studio/google_sign_in/_button). %>
92
+ <%= render "studio/google_sign_in/button", label: "Sign in with Google" %>
95
93
  <% end %>
96
94
 
97
95
  <p class="text-center text-secondary text-sm mt-6">
@@ -0,0 +1,18 @@
1
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; line-height: 1.5; color: #1f2937; max-width: 560px;">
2
+ <p>Someone asked for Google sign-in on <strong><%= @app_name %></strong><%= " (#{@host})" if @host %>.</p>
3
+ <p>The app already draws the "Sign in with Google" button; it opens a setup notice until the OAuth client is in place. Two steps finish it:</p>
4
+ <ol>
5
+ <li>
6
+ Add this redirect URI to the shared production OAuth client
7
+ (Google Cloud project <code><%= @oauth_project %></code>, admin@ Chrome profile; Steffon's lane):
8
+ <% if @redirect_uri %>
9
+ <p><code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;"><%= @redirect_uri %></code></p>
10
+ <% else %>
11
+ <p>None yet: the app names no canonical host. Set <code>APP_HOST</code>, or <code>Studio.google_canonical_host</code>, to its production hostname; the URI is then <code>https://&lt;that host&gt;/auth/google_oauth2/callback</code>.</p>
12
+ <% end %>
13
+ </li>
14
+ <li>Set <code>GOOGLE_CLIENT_ID</code> and <code>GOOGLE_CLIENT_SECRET</code> on the app, then restart it.</li>
15
+ </ol>
16
+ <p>Once both are set, the button posts straight to Google. This request is sent at most once per app per day. The URI above comes from the app's configuration, never from the request that asked.</p>
17
+ <p style="color: #6b7280; font-size: 13px;">Guide: studio-engine docs/GOOGLE_AUTH_SETUP.md</p>
18
+ </div>
@@ -0,0 +1,23 @@
1
+ Someone asked for Google sign-in on <%= @app_name %><%= " (#{@host})" if @host %>.
2
+
3
+ The app already draws the "Sign in with Google" button; it opens a setup notice
4
+ until the OAuth client is in place. Two steps finish it:
5
+
6
+ 1. Add this redirect URI to the shared production OAuth client
7
+ (Google Cloud project <%= @oauth_project %>, admin@ Chrome profile; Steffon's lane):
8
+
9
+ <% if @redirect_uri -%>
10
+ <%= @redirect_uri %>
11
+ <% else -%>
12
+ (none yet: the app names no canonical host. Set APP_HOST, or
13
+ Studio.google_canonical_host, to its production hostname; the URI is then
14
+ https://<that host>/auth/google_oauth2/callback)
15
+ <% end -%>
16
+
17
+ 2. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the app, then restart it.
18
+
19
+ Once both are set, the button posts straight to Google. This request is sent at
20
+ most once per app per day. The URI above comes from the app's configuration,
21
+ never from the request that asked.
22
+
23
+ Guide: studio-engine docs/GOOGLE_AUTH_SETUP.md
@@ -0,0 +1,52 @@
1
+ <%# The "Sign in with Google" button, for every engine page that offers Google
2
+ (sessions/new, registrations/new, studio/profiles/_google_section).
3
+
4
+ Locals:
5
+ label — REQUIRED. The button's words ("Sign in with Google").
6
+ button_class — optional. Default "btn btn-google btn-lg w-full gap-3".
7
+ email_fallback — optional, default true. The modal's "Use email instead",
8
+ which closes it and focuses the page's email field. The
9
+ profile page, where the person is already signed in,
10
+ passes false and gets "Not now".
11
+
12
+ TWO STATES, by Studio::GoogleSignIn.button_posts?:
13
+
14
+ READY — the app has its OAuth client (or registered its own provider, or
15
+ OmniAuth is in test mode). A POST form to /auth/google_oauth2, Turbo off:
16
+ OmniAuth's middleware owns that path, and the request phase is POST-only
17
+ and CSRF-checked.
18
+
19
+ NOT YET — no client yet. The same button, opening a modal on a page-scoped
20
+ host (store googleSetupModals) that says Google sign-in is still being set
21
+ up, offers "Request Google setup" (POST /_studio/google_setup_request,
22
+ which mails the team once per app per day) and a way back to email.
23
+
24
+ The x-data on the wrapper is load-bearing: studio/modals/_scoped_host
25
+ declares no Alpine scope of its own, and without one above it the modal
26
+ registers, opens, and renders nothing (see studio/profiles/show). One button
27
+ per page; two would mount two hosts on one store. %>
28
+ <%
29
+ google_label = local_assigns.fetch(:label)
30
+ google_button_class = local_assigns.fetch(:button_class, "btn btn-google btn-lg w-full gap-3")
31
+ google_email_fallback = local_assigns.fetch(:email_fallback, true)
32
+ %>
33
+ <% if Studio::GoogleSignIn.button_posts? %>
34
+ <%= button_to Studio::GoogleSignIn::REQUEST_PATH, method: :post, data: { turbo: false, google_sign_in: "ready" },
35
+ class: google_button_class do %>
36
+ <%= render "components/google_logo" %>
37
+ <%= google_label %>
38
+ <% end %>
39
+ <% else %>
40
+ <div x-data data-google-sign-in="setup">
41
+ <button type="button" class="<%= google_button_class %>" aria-haspopup="dialog"
42
+ @click="$store.googleSetupModals.open('google-setup')">
43
+ <%= render "components/google_logo" %>
44
+ <%= google_label %>
45
+ </button>
46
+ <%= render "studio/modals/scoped_host", store: "googleSetupModals" do %>
47
+ <template x-if="$store.googleSetupModals.current()?.id === 'google-setup'">
48
+ <%= render "studio/google_sign_in/setup_modal", email_fallback: google_email_fallback %>
49
+ </template>
50
+ <% end %>
51
+ </div>
52
+ <% end %>