studio-engine 0.137.0 → 0.138.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 +23 -0
- data/README.md +27 -1
- data/app/assets/tailwind/studio_engine/engine.css +228 -0
- data/app/controllers/concerns/studio/connection_flow.rb +1 -1
- data/app/controllers/sessions_controller.rb +12 -3
- data/app/controllers/studio/integrations_controller.rb +43 -0
- data/app/controllers/studio/local_reviews_controller.rb +12 -0
- data/app/javascript/studio/carousel.js +66 -0
- data/app/javascript/studio/controllers/carousel_controller.js +203 -1
- data/app/models/concerns/studio/account.rb +143 -0
- data/app/models/concerns/studio/omniauth_user.rb +9 -17
- data/app/models/error_log.rb +14 -5
- data/app/views/studio/_carousel.html.erb +58 -14
- data/app/views/studio/integrations/_card.html.erb +50 -0
- data/app/views/studio/integrations/index.html.erb +22 -0
- data/app/views/studio/integrations/show.html.erb +24 -0
- data/app/views/style/_tricks.html.erb +15 -0
- data/config/studio_tricks.yml +7 -0
- data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
- data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
- data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
- data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
- data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
- data/lib/generators/studio/connections/connections_generator.rb +10 -0
- data/lib/studio/connections.rb +53 -12
- data/lib/studio/integrations/egnyte.rb +69 -0
- data/lib/studio/integrations/microsoft_365.rb +63 -0
- data/lib/studio/integrations/quickbooks.rb +86 -0
- data/lib/studio/integrations.rb +265 -0
- data/lib/studio/node_tooling/README.md +9 -1
- data/lib/studio/node_tooling/e2e_helpers.js +93 -1
- data/lib/studio/node_tooling/playwright_base.js +51 -4
- data/lib/studio/roles.rb +17 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +59 -1
- metadata +15 -2
|
@@ -49,9 +49,22 @@
|
|
|
49
49
|
// The pause button (WCAG 2.2.2) stops it until pressed again; under reduced motion
|
|
50
50
|
// the row starts stopped. A move by autoplay writes nothing to the status line
|
|
51
51
|
// (APG: a rotating carousel does not announce). Without loop it stops at the end.
|
|
52
|
+
//
|
|
53
|
+
// A CASCADE row (class carousel-cascade) does not scroll. Its slides share one grid
|
|
54
|
+
// cell and each wears data-cascade-step, its place from the open slide (cascadeStep,
|
|
55
|
+
// cascadeSlot); engine.css lays the 3D stack out from it and transitions it. The
|
|
56
|
+
// open slide carries data-current. Every slide is a stop, so the arrows, dots, keys
|
|
57
|
+
// and autoplay move it as they move a spotlight, through the same move() and
|
|
58
|
+
// scrollToStop(). A swipe or a drag (pointer events, past DRAG_THRESHOLD) moves it
|
|
59
|
+
// too; a click on a side slide brings that slide to the front and follows no link,
|
|
60
|
+
// and keyboard focus landing in a side slide does the same, so Tab walks the set. A
|
|
61
|
+
// looping cascade takes no clones: the steps wrap round the set, and a slide that
|
|
62
|
+
// crosses the stack round the seam (cascadeJumps) takes no glide. A focused slide
|
|
63
|
+
// that leaves the window hands focus to the track, so the keys keep working.
|
|
52
64
|
import { Controller } from "@hotwired/stimulus"
|
|
53
65
|
import {
|
|
54
|
-
CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop,
|
|
66
|
+
CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, cascadeJumps, cascadeReach, cascadeSlot,
|
|
67
|
+
cascadeStep, cascadeSteps, cascadeTarget, centerStops, cloneCount, cloneLayout, dragSteps,
|
|
55
68
|
currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
|
|
56
69
|
perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
|
|
57
70
|
} from "studio/carousel"
|
|
@@ -66,8 +79,13 @@ export default class extends Controller {
|
|
|
66
79
|
this.settle = this.settle.bind(this)
|
|
67
80
|
this.clickClone = this.clickClone.bind(this)
|
|
68
81
|
this.loop = this.element.hasAttribute("data-carousel-loop")
|
|
82
|
+
this.cascade = this.element.classList.contains("carousel-cascade")
|
|
69
83
|
this.cloned = 0
|
|
70
84
|
this.removeClones()
|
|
85
|
+
if (this.cascade) {
|
|
86
|
+
this.connectCascade()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
71
89
|
this.scrollEnd = "onscrollend" in window
|
|
72
90
|
this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
|
|
73
91
|
if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
|
|
@@ -83,6 +101,179 @@ export default class extends Controller {
|
|
|
83
101
|
this.startAutoplay()
|
|
84
102
|
}
|
|
85
103
|
|
|
104
|
+
// -- the cascade --------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
connectCascade() {
|
|
107
|
+
const slides = this.slideTargets
|
|
108
|
+
const marked = slides.findIndex((slide) => slide.hasAttribute("data-current"))
|
|
109
|
+
this.active = marked >= 0 ? marked : 0
|
|
110
|
+
// A #fragment inside a slide opens on that slide.
|
|
111
|
+
const hash = window.location?.hash
|
|
112
|
+
if (hash && hash.length > 1) {
|
|
113
|
+
let target = null
|
|
114
|
+
try { target = document.getElementById(decodeURIComponent(hash.slice(1))) } catch { target = null }
|
|
115
|
+
const index = target ? slides.findIndex((slide) => slide.contains(target)) : -1
|
|
116
|
+
if (index >= 0) this.active = index
|
|
117
|
+
}
|
|
118
|
+
this.cascadeListeners = [
|
|
119
|
+
["pointerdown", (event) => this.dragStart(event)],
|
|
120
|
+
["pointermove", (event) => this.dragMove(event)],
|
|
121
|
+
["pointerup", (event) => this.dragEnd(event)],
|
|
122
|
+
["pointercancel", () => this.dragCancel()],
|
|
123
|
+
["click", (event) => this.clickSide(event), true],
|
|
124
|
+
["focusin", (event) => this.focusSide(event)],
|
|
125
|
+
["dragstart", (event) => event.preventDefault()]
|
|
126
|
+
]
|
|
127
|
+
this.cascadeListeners.forEach(([type, handler, capture]) => this.trackTarget.addEventListener(type, handler, { capture: !!capture }))
|
|
128
|
+
if (typeof ResizeObserver === "function") {
|
|
129
|
+
this.observer = new ResizeObserver(this.schedule)
|
|
130
|
+
this.observer.observe(this.trackTarget)
|
|
131
|
+
}
|
|
132
|
+
this.element.setAttribute("data-carousel-ready", "")
|
|
133
|
+
this.sync()
|
|
134
|
+
this.startAutoplay()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
disconnectCascade() {
|
|
138
|
+
this.cascadeListeners?.forEach(([type, handler, capture]) => this.trackTarget.removeEventListener(type, handler, { capture: !!capture }))
|
|
139
|
+
this.cascadeListeners = null
|
|
140
|
+
this.dragCancel()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The reach CSS lays out: 1 on a three-slide cascade or a narrow region, else 2.
|
|
144
|
+
reach() {
|
|
145
|
+
const css = parseFloat(getComputedStyle(this.trackTarget).getPropertyValue("--carousel-cascade-reach"))
|
|
146
|
+
return css > 0 ? css : cascadeReach(this.element.dataset.carouselVisible)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
syncCascade() {
|
|
150
|
+
const total = this.slideTargets.length
|
|
151
|
+
this.page = 1
|
|
152
|
+
this.positions = Array.from({ length: Math.max(1, total) }, (_, index) => index)
|
|
153
|
+
this.full = null
|
|
154
|
+
this.active = Math.min(Math.max(0, this.active || 0), Math.max(0, total - 1))
|
|
155
|
+
this.current = this.virtual = this.active
|
|
156
|
+
this.element.toggleAttribute("data-carousel-static", total < 2)
|
|
157
|
+
const wraps = this.loop && total > 1
|
|
158
|
+
if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && this.active === 0))
|
|
159
|
+
if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && this.active >= total - 1))
|
|
160
|
+
const steps = cascadeSteps(total, this.active, this.loop)
|
|
161
|
+
const jumps = this.laid ? cascadeJumps(this.laid, steps, this.active) : steps.map(() => false)
|
|
162
|
+
this.slideTargets.forEach((slide, index) => {
|
|
163
|
+
slide.setAttribute("data-cascade-step", cascadeSlot(steps[index]))
|
|
164
|
+
slide.toggleAttribute("data-cascade-jump", jumps[index])
|
|
165
|
+
slide.toggleAttribute("data-current", index === this.active)
|
|
166
|
+
})
|
|
167
|
+
this.laid = steps
|
|
168
|
+
this.dotTargets.forEach((dot, index) => {
|
|
169
|
+
dot.hidden = index >= total
|
|
170
|
+
if (index === this.active) dot.setAttribute("aria-current", "true")
|
|
171
|
+
else dot.removeAttribute("aria-current")
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Brings slide `index` to the front. A focused slide that leaves the window hands
|
|
176
|
+
// focus to the track (a hidden slide cannot hold it), so the keys keep working.
|
|
177
|
+
activate(index) {
|
|
178
|
+
const total = this.slideTargets.length
|
|
179
|
+
if (!(total > 0)) return
|
|
180
|
+
this.active = Math.min(total - 1, Math.max(0, index))
|
|
181
|
+
this.sync()
|
|
182
|
+
const focused = this.slideTargets.findIndex((slide) => slide.contains(document.activeElement))
|
|
183
|
+
if (focused >= 0 && Math.abs(cascadeStep(focused, this.active, total, this.loop)) > this.reach()) {
|
|
184
|
+
this.trackTarget.focus({ preventScroll: true })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
slideAt(target) {
|
|
189
|
+
const slide = target?.closest?.(".carousel-slide")
|
|
190
|
+
return slide && this.trackTarget.contains(slide) ? this.slideTargets.indexOf(slide) : -1
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A press on a side slide: the click that follows brings it to the front and opens
|
|
194
|
+
// nothing. The slide is read at the press, so a link whose focus already moved the
|
|
195
|
+
// stack still counts as a side slide's.
|
|
196
|
+
clickSide(event) {
|
|
197
|
+
if (this.dragged) {
|
|
198
|
+
this.dragged = false
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
event.stopPropagation()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const index = this.pressed ?? this.slideAt(event.target)
|
|
204
|
+
this.pressed = null
|
|
205
|
+
if (index < 0 || index === this.active) return
|
|
206
|
+
event.preventDefault()
|
|
207
|
+
event.stopPropagation()
|
|
208
|
+
this.interacted()
|
|
209
|
+
this.scrollToStop(index)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Keyboard focus in a side slide brings it to the front, so Tab walks the set. A
|
|
213
|
+
// mouse press focuses without :focus-visible and leaves the move to its click.
|
|
214
|
+
focusSide(event) {
|
|
215
|
+
const index = this.slideAt(event.target)
|
|
216
|
+
if (index < 0 || index === this.active) return
|
|
217
|
+
let keyboard = true
|
|
218
|
+
try { keyboard = event.target.matches(":focus-visible") } catch { keyboard = true }
|
|
219
|
+
if (!keyboard) return
|
|
220
|
+
this.interacted()
|
|
221
|
+
this.scrollToStop(index, { announce: false })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
dragStart(event) {
|
|
225
|
+
if (event.button > 0 || this.drag) return
|
|
226
|
+
const index = this.slideAt(event.target)
|
|
227
|
+
this.pressed = index >= 0 && index !== this.active ? index : null
|
|
228
|
+
this.dragged = false
|
|
229
|
+
this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, live: false }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
dragMove(event) {
|
|
233
|
+
const drag = this.drag
|
|
234
|
+
if (!drag || event.pointerId !== drag.id) return
|
|
235
|
+
const dx = event.clientX - drag.x
|
|
236
|
+
if (!drag.live) {
|
|
237
|
+
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(event.clientY - drag.y)) return
|
|
238
|
+
drag.live = true
|
|
239
|
+
// Captured only once it is a drag, so a plain click still lands on its link.
|
|
240
|
+
try { this.trackTarget.setPointerCapture(drag.id) } catch { /* the pointer is gone */ }
|
|
241
|
+
this.element.setAttribute("data-carousel-dragging", "")
|
|
242
|
+
}
|
|
243
|
+
this.trackTarget.style.setProperty("--carousel-cascade-drag", `${Math.round(dx * 0.35)}px`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
dragEnd(event) {
|
|
247
|
+
const drag = this.drag
|
|
248
|
+
if (!drag || event.pointerId !== drag.id) return
|
|
249
|
+
const live = drag.live
|
|
250
|
+
this.dragCancel()
|
|
251
|
+
if (!live) return
|
|
252
|
+
const by = dragSteps(event.clientX - drag.x, this.stepWidth())
|
|
253
|
+
this.pressed = null
|
|
254
|
+
this.dragged = true
|
|
255
|
+
// The click a drag ends in is swallowed by clickSide; a touch drag fires none.
|
|
256
|
+
setTimeout(() => { this.dragged = false }, 0)
|
|
257
|
+
if (by === 0) return
|
|
258
|
+
this.interacted()
|
|
259
|
+
const total = this.slideTargets.length
|
|
260
|
+
const to = cascadeTarget(this.active, by, total, this.loop)
|
|
261
|
+
if (to !== this.active) this.scrollToStop(to)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dragCancel() {
|
|
265
|
+
this.drag = null
|
|
266
|
+
this.element?.removeAttribute("data-carousel-dragging")
|
|
267
|
+
this.trackTarget?.style.removeProperty("--carousel-cascade-drag")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The width a drag crosses per step: the open slide's, or a fifth of the row.
|
|
271
|
+
stepWidth() {
|
|
272
|
+
const open = this.slideTargets[this.active]
|
|
273
|
+
const width = open ? open.getBoundingClientRect().width : 0
|
|
274
|
+
return width > 0 ? width * 0.6 : this.trackTarget.clientWidth / 5
|
|
275
|
+
}
|
|
276
|
+
|
|
86
277
|
openAtStart() {
|
|
87
278
|
const start = Number(this.element.dataset.carouselStart)
|
|
88
279
|
if (!(start > 0)) return
|
|
@@ -101,6 +292,7 @@ export default class extends Controller {
|
|
|
101
292
|
}
|
|
102
293
|
|
|
103
294
|
disconnect() {
|
|
295
|
+
if (this.cascade) this.disconnectCascade()
|
|
104
296
|
this.trackTarget.removeEventListener("scroll", this.schedule)
|
|
105
297
|
this.trackTarget.removeEventListener("scrollend", this.settle)
|
|
106
298
|
this.trackTarget.removeEventListener("click", this.clickClone)
|
|
@@ -332,6 +524,10 @@ export default class extends Controller {
|
|
|
332
524
|
}
|
|
333
525
|
|
|
334
526
|
sync() {
|
|
527
|
+
if (this.cascade) {
|
|
528
|
+
this.syncCascade()
|
|
529
|
+
return
|
|
530
|
+
}
|
|
335
531
|
const track = this.trackTarget
|
|
336
532
|
const { positions, page } = this.measure()
|
|
337
533
|
const ends = hiddenEnds(track)
|
|
@@ -412,6 +608,12 @@ export default class extends Controller {
|
|
|
412
608
|
// row the index is virtual: a clone's place glides there and settle() jumps home,
|
|
413
609
|
// but under reduced motion the row goes straight to the real slide.
|
|
414
610
|
scrollToStop(index, { announce = true } = {}) {
|
|
611
|
+
if (this.cascade) {
|
|
612
|
+
if (!(index >= 0 && index < this.slideTargets.length)) return
|
|
613
|
+
this.activate(index)
|
|
614
|
+
if (announce && this.hasStatusTarget) this.statusTarget.textContent = positionText(index, 1, this.slideTargets.length)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
415
617
|
const reduce = window.matchMedia?.(REDUCE).matches
|
|
416
618
|
if (this.cloned) {
|
|
417
619
|
const total = this.positions.length
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The engine-owned account contract (database audit 2026-11-07, section 3,
|
|
5
|
+
# "Recommended primitive: Studio::Account"). One copy of the rules nine apps
|
|
6
|
+
# typed by hand:
|
|
7
|
+
#
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# include Studio::Account
|
|
10
|
+
# studio_roles %w[viewer admin] # optional: narrow, never widen
|
|
11
|
+
#
|
|
12
|
+
# def name_slug = name.to_s.parameterize # Sluggable's, the host's own
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# It gives the host:
|
|
16
|
+
#
|
|
17
|
+
# * `admin?` (role "admin") and a default `display_name`;
|
|
18
|
+
# * `normalizes :email`: stripped, downcased, and a blank address stored as
|
|
19
|
+
# NULL, so "" and " " never collide on the unique index or match a lookup;
|
|
20
|
+
# * `validates :role, inclusion:` against Studio::Roles, or the host's
|
|
21
|
+
# narrower list, with the default role filled on create;
|
|
22
|
+
# * Sluggable and Studio::OmniauthUser (the engine's Google find-or-create,
|
|
23
|
+
# with its race rescue);
|
|
24
|
+
# * one safe email lookup, `User.account_for_email(address)` and, for code
|
|
25
|
+
# that holds a model class rather than including this,
|
|
26
|
+
# `Studio::Account.find_by_email(User, address)`.
|
|
27
|
+
#
|
|
28
|
+
# THE NAME. The audit named the concern Studio::User. It cannot be: inside
|
|
29
|
+
# `module Studio`, a bare `User` resolves to Studio::User before ::User, and
|
|
30
|
+
# the engine reads the host's User bare in Studio::ErrorHandling#current_user,
|
|
31
|
+
# Studio::LinkConsumption and Studio::LocalReviewsController. A Studio::User
|
|
32
|
+
# constant would hand those the concern instead of the model.
|
|
33
|
+
# test/integration/studio_account_test.rb holds that line.
|
|
34
|
+
#
|
|
35
|
+
# THE LOOKUP RULE (the account-takeover class the hub and Turf fixed on
|
|
36
|
+
# 2026-11-08). `find_by(email: nil)` matches ANY account without an email:
|
|
37
|
+
# every wallet and guest sign-up. So a blank address finds nobody, the match
|
|
38
|
+
# is case-insensitive (`LOWER(email)`), and a lookup that LINKS a Google
|
|
39
|
+
# identity to the account it finds takes Google's verification first.
|
|
40
|
+
module Account
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
included do
|
|
44
|
+
include Sluggable
|
|
45
|
+
include Studio::OmniauthUser
|
|
46
|
+
|
|
47
|
+
class_attribute :studio_role_list, instance_writer: false, default: Studio::Roles
|
|
48
|
+
|
|
49
|
+
normalizes :email, with: ->(address) { Studio::Account.normalize_email(address) }
|
|
50
|
+
|
|
51
|
+
before_validation :studio_fill_default_role, on: :create
|
|
52
|
+
validates :role, inclusion: { in: ->(record) { record.class.studio_role_list } },
|
|
53
|
+
if: -> { has_attribute?(:role) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class_methods do
|
|
57
|
+
# Narrows the roles this app accepts. Every one must be in Studio::Roles:
|
|
58
|
+
# a host cannot invent a role the engine does not know.
|
|
59
|
+
def studio_roles(list)
|
|
60
|
+
roles = Array(list).map(&:to_s).uniq
|
|
61
|
+
raise ArgumentError, "studio_roles needs at least one role" if roles.empty?
|
|
62
|
+
|
|
63
|
+
unknown = roles - Studio::Roles
|
|
64
|
+
if unknown.any?
|
|
65
|
+
raise ArgumentError, "studio_roles may only narrow Studio::Roles (#{Studio::Roles.join(', ')}); " \
|
|
66
|
+
"unknown: #{unknown.join(', ')}. Add a role to the engine, for every app."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
self.studio_role_list = roles.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The role a new account takes: viewer, or the first of a list without it.
|
|
73
|
+
def studio_default_role
|
|
74
|
+
studio_role_list.include?(Studio::DEFAULT_ROLE) ? Studio::DEFAULT_ROLE : studio_role_list.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The account holding this address, or nil. A blank address finds nobody.
|
|
78
|
+
def account_for_email(address)
|
|
79
|
+
Studio::Account.find_by_email(self, address)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# " Mia@Example.COM " -> "mia@example.com"; blank -> nil.
|
|
85
|
+
def normalize_email(value)
|
|
86
|
+
email = value.to_s.strip.downcase
|
|
87
|
+
email.empty? ? nil : email
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The one safe lookup by address, for any model with an `email` column.
|
|
91
|
+
# Blank finds nobody; the compare is LOWER(email), so a row stored before
|
|
92
|
+
# `normalizes` still matches.
|
|
93
|
+
def find_by_email(model, address)
|
|
94
|
+
email = normalize_email(address)
|
|
95
|
+
return nil if email.nil?
|
|
96
|
+
return nil unless model.respond_to?(:attribute_names) && model.attribute_names.include?("email")
|
|
97
|
+
|
|
98
|
+
model.where(model.arel_table[:email].lower.eq(email)).first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The account a lost race left behind: a concurrent sign-in made the row
|
|
102
|
+
# this one collided with. The Google id always identifies it; the address
|
|
103
|
+
# only when Google verified it, because an unverified address proves
|
|
104
|
+
# nothing about who holds the mailbox. Nobody found: the collision is
|
|
105
|
+
# logged with ErrorLog.capture! and the answer is nil, never a stranger.
|
|
106
|
+
def race_winner(model, provider:, uid:, email: nil, email_verified: false, error: nil)
|
|
107
|
+
found = model.find_by(provider: provider, uid: uid) if provider.present? && uid.present?
|
|
108
|
+
found ||= find_by_email(model, email) if email_verified
|
|
109
|
+
return found if found
|
|
110
|
+
|
|
111
|
+
log_unmatched_race(error)
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def log_unmatched_race(error)
|
|
118
|
+
return unless error
|
|
119
|
+
|
|
120
|
+
Rails.logger.warn("[studio account] sign-in collided with nobody to find: #{error.class}: #{error.message.to_s[0, 300]}")
|
|
121
|
+
ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Rails.logger.warn("[studio account] could not log the collision: #{e.class}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def admin?
|
|
128
|
+
role.to_s == "admin"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def display_name
|
|
132
|
+
try(:name).presence || try(:email).to_s.split("@").first.presence || "User"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def studio_fill_default_role
|
|
138
|
+
return unless has_attribute?(:role) && role.blank?
|
|
139
|
+
|
|
140
|
+
self.role = self.class.studio_default_role
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -69,15 +69,14 @@ module Studio
|
|
|
69
69
|
create_with_free_slug(model, attributes, email_verified: email_verified)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Kept under these names for callers that predate Studio::Account, which
|
|
73
|
+
# now owns the one safe lookup: a blank address finds nobody.
|
|
72
74
|
def normalize_email(value)
|
|
73
|
-
|
|
74
|
-
email.empty? ? nil : email
|
|
75
|
+
Studio::Account.normalize_email(value)
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def find_by_email(model, email)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
model.where(model.arel_table[:email].lower.eq(email)).first
|
|
79
|
+
Studio::Account.find_by_email(model, email)
|
|
81
80
|
end
|
|
82
81
|
|
|
83
82
|
private
|
|
@@ -99,13 +98,15 @@ module Studio
|
|
|
99
98
|
# verified it, the same rule as step 2 above. An unverified address
|
|
100
99
|
# that collides with someone else's row hands that row to nobody.
|
|
101
100
|
found = model.find_by(provider: assignable[:provider], uid: assignable[:uid])
|
|
102
|
-
found ||= find_by_email(model, assignable[:email]) if email_verified
|
|
101
|
+
found ||= find_by_email(model, assignable[:email]) if email_verified
|
|
103
102
|
return found if found
|
|
104
103
|
end
|
|
105
104
|
end
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
# Nobody to find on either attempt: Studio::Account.race_winner logs it.
|
|
107
|
+
Studio::Account.race_winner(model, provider: attributes[:provider], uid: attributes[:uid],
|
|
108
|
+
email: attributes[:email], email_verified: email_verified,
|
|
109
|
+
error: last_error)
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
def slug_needs_fallback?(model, record)
|
|
@@ -126,15 +127,6 @@ module Studio
|
|
|
126
127
|
record.define_singleton_method(:name_slug) { id ? "user-#{id}" : "user-#{token}" }
|
|
127
128
|
end
|
|
128
129
|
|
|
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
130
|
def column?(model, name)
|
|
139
131
|
model.respond_to?(:attribute_names) && model.attribute_names.include?(name)
|
|
140
132
|
end
|
data/app/models/error_log.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
class ErrorLog < ApplicationRecord
|
|
2
|
+
# `slug` is NOT NULL and unique (RequireSlugsOnEngineTables), so every insert
|
|
3
|
+
# carries one, including a host's bare `ErrorLog.create!`. Sluggable writes a
|
|
4
|
+
# unique placeholder before the insert and settles it to `error-log-<id>` right
|
|
5
|
+
# after, inside the create, once the id exists.
|
|
6
|
+
include Sluggable
|
|
7
|
+
|
|
2
8
|
belongs_to :target, polymorphic: true, optional: true
|
|
3
9
|
belongs_to :parent, polymorphic: true, optional: true
|
|
4
10
|
|
|
5
|
-
def to_param
|
|
6
|
-
slug
|
|
7
|
-
end
|
|
8
|
-
|
|
9
11
|
def inspect_field
|
|
10
12
|
read_attribute(:inspect)
|
|
11
13
|
end
|
|
@@ -25,7 +27,6 @@ class ErrorLog < ApplicationRecord
|
|
|
25
27
|
inspect: safe_inspect,
|
|
26
28
|
backtrace: cleaned.to_json
|
|
27
29
|
)
|
|
28
|
-
log.update_column(:slug, "error-log-#{log.id}")
|
|
29
30
|
|
|
30
31
|
# Fan out to Sentry if the host app loaded sentry-ruby. Guard with
|
|
31
32
|
# respond_to? so we don't blow up on old SDK versions. The DB ErrorLog
|
|
@@ -42,4 +43,12 @@ class ErrorLog < ApplicationRecord
|
|
|
42
43
|
|
|
43
44
|
log
|
|
44
45
|
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The id does not exist before the insert, so the first call returns a unique
|
|
50
|
+
# placeholder; Sluggable calls it again after the insert.
|
|
51
|
+
def name_slug
|
|
52
|
+
id ? "error-log-#{id}" : "error-log-new-#{SecureRandom.hex(8)}"
|
|
53
|
+
end
|
|
45
54
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false) %>
|
|
1
|
+
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false, visible: nil) %>
|
|
2
2
|
<%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
|
|
3
3
|
long to grid. Render it as a layout, so each slide is your own block:
|
|
4
4
|
|
|
@@ -18,18 +18,25 @@
|
|
|
18
18
|
slide is "2 of 6".
|
|
19
19
|
data data attributes for the region, such as a test hook.
|
|
20
20
|
class_name classes for the region, such as its width.
|
|
21
|
-
style :standard (slides snap to the start)
|
|
21
|
+
style :standard (slides snap to the start), :spotlight (the
|
|
22
22
|
centred slide is full size and colour, its neighbours smaller,
|
|
23
|
-
faded and grey, and the arrows and keys move one slide)
|
|
23
|
+
faded and grey, and the arrows and keys move one slide), or
|
|
24
|
+
:cascade (a 3D stack: the open slide in front at full size,
|
|
25
|
+
the slides either side stepping back behind it, each step
|
|
26
|
+
smaller and dimmer; see THE CASCADE below).
|
|
27
|
+
visible a cascade's slides in view: 5 (the default: the open slide and
|
|
28
|
+
two each side) or 3. A region under 36rem wide shows 3 whatever
|
|
29
|
+
it asks. Only a cascade takes it.
|
|
24
30
|
caption a callable that writes a line under a slide from its item. A
|
|
25
31
|
standard row shows a small line under every slide; the
|
|
26
|
-
spotlight
|
|
32
|
+
spotlight and the cascade show it under the open slide only.
|
|
27
33
|
start the slide the row opens on: an index from 0, or :middle (the
|
|
28
34
|
slide at items.size / 2). Defaults to :middle for a spotlight
|
|
29
35
|
that does not loop, so both halves of the row hold a slide at
|
|
30
36
|
load, and to 0 for a standard row and for a looping spotlight
|
|
31
37
|
(its wrap already shows the last slide left of the first, so it
|
|
32
|
-
opens on the host's first item).
|
|
38
|
+
opens on the host's first item). A cascade takes the spotlight's
|
|
39
|
+
defaults. An explicit start: wins. The browser sets it before the first paint where
|
|
33
40
|
it has scroll-initial-target; elsewhere the controller sets it
|
|
34
41
|
when it connects, with no glide and nothing announced. A link
|
|
35
42
|
to an id inside the row (#fragment) wins over the start.
|
|
@@ -60,6 +67,18 @@
|
|
|
60
67
|
transitions it. Under reduced motion nothing scales: the neighbours are only
|
|
61
68
|
dimmed.
|
|
62
69
|
|
|
70
|
+
THE CASCADE (engine.css, carousel-cascade) does not scroll. Its slides share
|
|
71
|
+
one grid cell, and each wears data-cascade-step, its place from the open slide:
|
|
72
|
+
-2 to 2 in view, "before" and "after" past the window. The partial writes the
|
|
73
|
+
steps of the first paint and the controller rewrites them on a move, so CSS
|
|
74
|
+
lays the stack out (perspective, translateZ, a slight turn toward the centre)
|
|
75
|
+
and transitions it. An arrow, a dot, a key, a swipe or drag, autoplay, a click
|
|
76
|
+
on a side slide and keyboard focus in one bring that slide to the front; a
|
|
77
|
+
click on a side slide opens nothing. A looping cascade needs no clones: the
|
|
78
|
+
steps wrap round the set, so the window is never wider than the set. Under
|
|
79
|
+
reduced motion it lays flat, as a spotlight does, and swaps with no glide. If
|
|
80
|
+
its controller fails it falls back to a plain scrolling row.
|
|
81
|
+
|
|
63
82
|
THE TRACK IS A NATIVE SCROLL-SNAP ROW (engine.css, carousel). A swipe, a
|
|
64
83
|
trackpad, a wheel and a focused link move it with no script. The carousel
|
|
65
84
|
controller (studio/controllers/carousel_controller) adds the edge fade, the
|
|
@@ -80,16 +99,25 @@
|
|
|
80
99
|
per_view = per_view.to_i if per_view == per_view.floor
|
|
81
100
|
items = items.to_a
|
|
82
101
|
total = items.size
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
style_name = style.to_s
|
|
103
|
+
unless %w[standard spotlight cascade].include?(style_name)
|
|
104
|
+
raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard, :spotlight or :cascade"
|
|
105
|
+
end
|
|
106
|
+
spotlight = style_name == "spotlight"
|
|
107
|
+
cascade = style_name == "cascade"
|
|
108
|
+
# The spotlight and the cascade both centre one slide: they share its defaults.
|
|
109
|
+
centred = spotlight || cascade
|
|
110
|
+
if cascade
|
|
111
|
+
visible = 5 if visible.nil?
|
|
112
|
+
raise ArgumentError, "studio/carousel visible: #{visible.inspect} is not 3 or 5" unless [3, 5].include?(visible)
|
|
113
|
+
elsif !visible.nil?
|
|
114
|
+
raise ArgumentError, "studio/carousel visible: only a style: :cascade row takes it"
|
|
115
|
+
end
|
|
88
116
|
loop = loop ? true : false
|
|
89
117
|
wraps = loop && total > 1
|
|
90
118
|
# A looping spotlight has a left neighbour at its first slide (the clones), so it
|
|
91
119
|
# opens there; one that does not loop opens mid-row so neither half is empty.
|
|
92
|
-
start =
|
|
120
|
+
start = centred && !wraps ? :middle : 0 if start.nil?
|
|
93
121
|
start_index = case start
|
|
94
122
|
when :middle, "middle" then total / 2
|
|
95
123
|
when Integer then start.clamp(0, [total - 1, 0].max)
|
|
@@ -100,20 +128,36 @@
|
|
|
100
128
|
raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
|
|
101
129
|
end
|
|
102
130
|
autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
|
|
103
|
-
arrow_noun =
|
|
131
|
+
arrow_noun = centred ? "slide" : "slides"
|
|
132
|
+
# A cascade slide's place from the open slide (studio/carousel's cascadeStep and
|
|
133
|
+
# cascadeSlot): signed, the short way round a looping set, the later side on a tie.
|
|
134
|
+
cascade_slot = lambda do |index|
|
|
135
|
+
step = index - start_index
|
|
136
|
+
if wraps
|
|
137
|
+
step %= total
|
|
138
|
+
step -= total if step > total / 2.0
|
|
139
|
+
end
|
|
140
|
+
step < -2 ? "before" : step > 2 ? "after" : step.to_s
|
|
141
|
+
end
|
|
104
142
|
track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
|
|
105
143
|
%>
|
|
106
|
-
<%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), class_name].compact.join(" "),
|
|
144
|
+
<%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), ("carousel-cascade" if cascade), class_name].compact.join(" "),
|
|
107
145
|
role: "region",
|
|
108
146
|
aria: { roledescription: "carousel", label: label },
|
|
109
147
|
style: "--carousel-per-view: #{per_view}",
|
|
110
148
|
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?),
|
|
111
|
-
carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop) }.compact.merge(data)) do %>
|
|
149
|
+
carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop), carousel_visible: (visible.to_s if cascade) }.compact.merge(data)) do %>
|
|
112
150
|
<%# The track takes focus for the keys, so it carries a name: a group, since a
|
|
113
151
|
bare div may not be named. %>
|
|
114
152
|
<div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
|
|
115
153
|
aria-label="<%= label %> slides" data-carousel-target="track">
|
|
116
154
|
<% items.each_with_index do |item, index| %>
|
|
155
|
+
<% if cascade %>
|
|
156
|
+
<div class="carousel-slide" role="group" aria-roledescription="slide"
|
|
157
|
+
aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
|
|
158
|
+
data-carousel-target="slide" data-cascade-step="<%= cascade_slot.call(index) %>"<%= ' data-current'.html_safe if index == start_index %>><div class="carousel-cascade-body"><%= yield(item) %></div><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
|
|
159
|
+
<% next %>
|
|
160
|
+
<% end %>
|
|
117
161
|
<div class="carousel-slide" role="group" aria-roledescription="slide"
|
|
118
162
|
aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
|
|
119
163
|
data-carousel-target="slide"<%= ' data-carousel-start-slide'.html_safe if start_index.positive? && index == start_index %><%= ' data-current'.html_safe if spotlight && index == start_index %>><% if spotlight %><div class="carousel-spotlight-body"><%= yield(item) %></div><% else %><%= yield(item) %><% end %><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%# locals: (integration:, connections:, return_to:, linked:) %>
|
|
2
|
+
<%# One integration's card: status, what keeps it off, Connect (a POST, with
|
|
3
|
+
the account field when the admin names the account at Connect), and its
|
|
4
|
+
connections with Disconnect. No token, and no part of one, is shown. %>
|
|
5
|
+
<% provider = integration.provider %>
|
|
6
|
+
<% status = integration.status %>
|
|
7
|
+
<section class="card p-4" data-studio-integration-card="<%= integration.key %>" data-status="<%= status %>">
|
|
8
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
9
|
+
<h2 class="text-heading font-semibold">
|
|
10
|
+
<% if linked %><a href="<%= integration_path_for(integration) %>" class="hover:underline"><%= integration.label %></a><% else %><%= integration.label %><% end %>
|
|
11
|
+
</h2>
|
|
12
|
+
<span class="badge" data-studio-integration-status><%= integration.status_label %></span>
|
|
13
|
+
</div>
|
|
14
|
+
<p class="text-sm text-secondary mt-1"><%= integration.summary %></p>
|
|
15
|
+
|
|
16
|
+
<% integration.missing.each do |reason| %>
|
|
17
|
+
<p class="text-sm text-muted mt-2" data-studio-integration-missing><%= reason %></p>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% if connections.any? %>
|
|
21
|
+
<ul class="mt-3 divide-y divide-subtle" data-studio-integration-connections>
|
|
22
|
+
<% connections.each do |connection| %>
|
|
23
|
+
<li class="py-2 flex flex-wrap items-center justify-between gap-2" data-studio-integration-connection="<%= connection.id %>">
|
|
24
|
+
<div>
|
|
25
|
+
<p class="text-sm text-heading font-medium"><%= connection.label %></p>
|
|
26
|
+
<p class="text-xs text-muted">
|
|
27
|
+
Connected <%= connection.connected_at.utc.strftime("%Y-%m-%d %H:%M UTC") %><% if connection.connected_by.present? %> by <%= connection.connected_by %><% end %><% if connection.metadata.to_h["environment"].present? %> · <%= connection.metadata["environment"] %><% end %>
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
<%= button_to "Disconnect", integration_disconnect_path(integration, connection), method: :delete,
|
|
31
|
+
params: { return_to: }, class: "btn btn-outline btn-sm",
|
|
32
|
+
form: { data: { turbo_confirm: "Disconnect #{connection.label}?" } } %>
|
|
33
|
+
</li>
|
|
34
|
+
<% end %>
|
|
35
|
+
</ul>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= form_with url: integration_connect_path(integration), method: :post, local: true,
|
|
39
|
+
class: "mt-3 flex flex-wrap items-end gap-2", data: { studio_integration_connect: integration.key, turbo: false } do |form| %>
|
|
40
|
+
<%= hidden_field_tag :return_to, return_to %>
|
|
41
|
+
<% if provider.account_source == :connect %>
|
|
42
|
+
<label class="text-sm text-secondary">
|
|
43
|
+
<span class="block mb-1"><%= integration.definition.account_label %></span>
|
|
44
|
+
<%= text_field_tag provider.account_param, nil, class: "input-field py-2", required: true, autocomplete: "off",
|
|
45
|
+
placeholder: integration.definition.account_hint %>
|
|
46
|
+
</label>
|
|
47
|
+
<% end %>
|
|
48
|
+
<%= form.submit(connections.any? ? "Connect another" : "Connect", class: "btn btn-primary btn-sm") %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</section>
|