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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +43 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +323 -0
- data/app/assets/tailwind/studio_engine/engine.css +44 -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/helpers/studio_theme_helper.rb +1 -1
- 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/type_scale.rb +64 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +63 -0
- data/studio-engine.gemspec +8 -0
- metadata +75 -1
|
@@ -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
|
-
|
|
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 %>
|