studio-engine 0.118.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +2 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +323 -0
- data/app/controllers/omniauth_callbacks_controller.rb +15 -2
- data/app/controllers/studio/google_setup_requests_controller.rb +66 -0
- data/app/helpers/studio/loader_helper.rb +79 -0
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/loader.js +214 -0
- data/app/mailers/studio/google_setup_mailer.rb +29 -0
- data/app/models/concerns/studio/omniauth_user.rb +153 -0
- data/app/views/registrations/new.html.erb +3 -5
- data/app/views/sessions/new.html.erb +3 -5
- data/app/views/studio/google_setup_mailer/setup_requested.html.erb +18 -0
- data/app/views/studio/google_setup_mailer/setup_requested.text.erb +23 -0
- data/app/views/studio/google_sign_in/_button.html.erb +52 -0
- data/app/views/studio/google_sign_in/_setup_modal.html.erb +55 -0
- data/app/views/studio/loader/_loader.html.erb +75 -0
- data/app/views/studio/loader/_overlay.html.erb +16 -0
- data/app/views/studio/profiles/_google_section.html.erb +5 -5
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/engine.rb +25 -0
- data/lib/studio/google_sign_in.rb +361 -0
- data/lib/studio/loader.rb +158 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +53 -0
- data/studio-engine.gemspec +8 -0
- metadata +74 -1
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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://<that host>/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 %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%# The Google setup modal, opened by studio/google_sign_in/_button while the
|
|
2
|
+
app has no OAuth client. One root element: the host's template x-if clones
|
|
3
|
+
only the first. No double quote inside any x-data or @click below.
|
|
4
|
+
|
|
5
|
+
Locals:
|
|
6
|
+
email_fallback — true: "Use email instead" closes the modal and focuses
|
|
7
|
+
the page's email field. false: "Not now" only closes it.
|
|
8
|
+
The focus waits out the close: the host hands focus
|
|
9
|
+
back to the opener when its last card leaves (220ms,
|
|
10
|
+
CLOSE_ANIM_MS), which would otherwise undo it.
|
|
11
|
+
|
|
12
|
+
"Request Google setup" is a plain form POST with Turbo off, so it works with
|
|
13
|
+
the same CSRF token as every other engine form, and the flash on the page it
|
|
14
|
+
lands back on says what happened. Hidden when the host draws its own routes
|
|
15
|
+
without Studio.routes, which leaves nowhere to send it. %>
|
|
16
|
+
<%
|
|
17
|
+
setup_email_fallback = local_assigns.fetch(:email_fallback, true)
|
|
18
|
+
setup_request_path = respond_to?(:studio_google_setup_request_path) ? studio_google_setup_request_path : nil
|
|
19
|
+
%>
|
|
20
|
+
<div class="text-center" data-google-setup-modal>
|
|
21
|
+
<div class="flex justify-end -mt-2 -mr-2">
|
|
22
|
+
<button type="button" @click="$store.googleSetupModals.close()"
|
|
23
|
+
class="text-secondary hover:text-heading text-xl leading-none"
|
|
24
|
+
aria-label="Close">×</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="mx-auto mb-4 w-12 h-12 rounded-full bg-white border border-subtle shadow-sm flex items-center justify-center">
|
|
28
|
+
<%= render "components/google_logo" %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<h3 class="text-heading font-bold text-lg leading-tight">Google sign-in is almost ready</h3>
|
|
32
|
+
<p class="text-secondary text-sm mt-2">
|
|
33
|
+
Google sign-in is still being set up for <%= Studio.app_name %>.
|
|
34
|
+
<% if setup_request_path %>Ask the team to finish it, and<% else %>Until it is,<% end %>
|
|
35
|
+
<% if setup_email_fallback %>sign in with your email in the meantime.<% else %>use another sign-in method in the meantime.<% end %>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<div class="mt-6 flex flex-col gap-3">
|
|
39
|
+
<% if setup_request_path %>
|
|
40
|
+
<%= button_to "Request Google setup", setup_request_path, method: :post,
|
|
41
|
+
data: { turbo: false }, class: "btn btn-primary w-full", form: { class: "w-full" } %>
|
|
42
|
+
<% end %>
|
|
43
|
+
|
|
44
|
+
<% if setup_email_fallback %>
|
|
45
|
+
<button type="button" class="btn btn-neutral w-full"
|
|
46
|
+
@click="$store.googleSetupModals.close(); setTimeout(() => document.querySelector('input[type=email]')?.focus(), 260)">
|
|
47
|
+
Use email instead
|
|
48
|
+
</button>
|
|
49
|
+
<% else %>
|
|
50
|
+
<button type="button" class="btn btn-neutral w-full" @click="$store.googleSetupModals.close()">
|
|
51
|
+
Not now
|
|
52
|
+
</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<%# locals: (style:, size:, icon:, art:, label:, percent:, mono:, pending:, uid:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
The branded loader (Studio::LoaderHelper#studio_loader, docs/LOADER.md): ONE
|
|
4
|
+
class, .studio-loader, sized by --studio-loader-size, styled per
|
|
5
|
+
data-loader-style by engine-motion.css (section "Branded loader"). The icon is
|
|
6
|
+
the app's own file; a signature style inlines the art's marked layers over it.
|
|
7
|
+
|
|
8
|
+
A pending loader sits inside a button: hidden from assistive tech (the button
|
|
9
|
+
carries aria-busy) and from sight until the button or its form is busy.
|
|
10
|
+
%>
|
|
11
|
+
<%
|
|
12
|
+
extra = html.dup
|
|
13
|
+
data = (extra.delete(:data) || {}).merge(loader_style: style.to_s.tr("_", "-"))
|
|
14
|
+
data[:loader_pending] = "" if pending
|
|
15
|
+
data[:loader_mono] = "" if mono
|
|
16
|
+
data[:loader_percent] = "" unless percent.nil?
|
|
17
|
+
vars = [ "--studio-loader-size: #{size}" ]
|
|
18
|
+
vars << "--studio-loader-percent: #{percent}" unless percent.nil?
|
|
19
|
+
vars << extra.delete(:style) if extra[:style]
|
|
20
|
+
classes = [ "studio-loader", extra.delete(:class) ].compact.join(" ")
|
|
21
|
+
draw_icon = lambda do |modifier = nil|
|
|
22
|
+
css = [ "studio-loader-icon", modifier ].compact.join(" ")
|
|
23
|
+
if icon.blank?
|
|
24
|
+
tag.span(class: css, data: { loader_blank: "" })
|
|
25
|
+
elsif mono
|
|
26
|
+
tag.span(class: css, style: "--studio-loader-mask: url(\"#{icon.to_s.gsub('"', '%22')}\")")
|
|
27
|
+
else
|
|
28
|
+
tag.img(src: icon, alt: "", class: css, draggable: "false", decoding: "async")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
aria = pending ? { hidden: "true" } : { live: "polite" }
|
|
32
|
+
%>
|
|
33
|
+
<%= tag.span(class: classes, role: (pending ? nil : "status"), aria: aria, data: data, style: vars.join("; "), **extra) do %>
|
|
34
|
+
<% case style %>
|
|
35
|
+
<% when :breathe %>
|
|
36
|
+
<span class="studio-loader-halo" aria-hidden="true"></span>
|
|
37
|
+
<%= draw_icon.call %>
|
|
38
|
+
<% when :liquid %>
|
|
39
|
+
<%= draw_icon.call("studio-loader-ghost") %>
|
|
40
|
+
<span class="studio-loader-fill" aria-hidden="true"><%= draw_icon.call %></span>
|
|
41
|
+
<% when :orbit %>
|
|
42
|
+
<span class="studio-loader-track" aria-hidden="true"></span>
|
|
43
|
+
<%= draw_icon.call %>
|
|
44
|
+
<span class="studio-loader-orb" aria-hidden="true"><span class="studio-loader-trail"></span><span class="studio-loader-spark"></span></span>
|
|
45
|
+
<% when :steel %>
|
|
46
|
+
<svg class="studio-loader-frame" viewBox="0 0 100 100" aria-hidden="true"><path d="M0,14V0H14M86,0H100V14M100,86V100H86M14,100H0V86"/></svg>
|
|
47
|
+
<%= draw_icon.call %>
|
|
48
|
+
<% when :weld_bead %>
|
|
49
|
+
<%= draw_icon.call %>
|
|
50
|
+
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
51
|
+
<defs><filter id="<%= uid %>-glow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="<%= (art[:width] / 52.86).round(2) %>" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
|
|
52
|
+
<% outline = art[:layers].first %>
|
|
53
|
+
<% layers = studio_loader_bead_layers(art) %>
|
|
54
|
+
<% [ layers.reject { |l| l[:glow] }, layers.select { |l| l[:glow] } ].each_with_index do |group, glowing| %>
|
|
55
|
+
<%= tag.g(filter: (glowing == 1 ? "url(##{uid}-glow)" : nil), transform: outline[:transform]) do %>
|
|
56
|
+
<% group.each do |layer| %>
|
|
57
|
+
<%= tag.path(d: outline[:d], pathLength: "100", class: "studio-loader-bead", data: { layer: layer[:n] },
|
|
58
|
+
"stroke-width": layer[:width], "stroke-opacity": layer[:opacity],
|
|
59
|
+
"stroke-dasharray": "#{layer[:dash]} #{(100 - layer[:dash]).round(2)}",
|
|
60
|
+
style: "--studio-loader-bead-length: #{layer[:dash]}") %>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% end %>
|
|
64
|
+
</svg>
|
|
65
|
+
<% when :sparkle %>
|
|
66
|
+
<%= draw_icon.call %>
|
|
67
|
+
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
68
|
+
<% art[:layers].each_with_index do |spark, index| %>
|
|
69
|
+
<%= tag.path(d: spark[:d], transform: spark[:transform], class: "studio-loader-sparkle",
|
|
70
|
+
style: "--studio-loader-i: #{index}; --studio-loader-sparks: #{art[:layers].size}") %>
|
|
71
|
+
<% end %>
|
|
72
|
+
</svg>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% unless pending || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
75
|
+
<% end %>
|