studio-engine 0.47.1 → 0.48.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 +253 -0
- data/README.md +19 -9
- data/app/controllers/studio/onboarding_controller.rb +5 -1
- data/app/controllers/studio/profiles_controller.rb +170 -0
- data/app/models/concerns/studio/user_profile.rb +111 -0
- data/app/services/studio/email_catalog.rb +50 -19
- data/app/views/components/_user_nav.html.erb +40 -6
- data/app/views/studio/emails/show.html.erb +16 -2
- data/app/views/studio/profiles/_avatar_section.html.erb +93 -0
- data/app/views/studio/profiles/_first_name_section.html.erb +26 -0
- data/app/views/studio/profiles/_google_section.html.erb +56 -0
- data/app/views/studio/profiles/show.html.erb +73 -0
- data/lib/studio/oauth_identity.rb +95 -0
- data/lib/studio/profile_image.rb +43 -0
- data/lib/studio/profile_sections.rb +153 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +89 -0
- metadata +11 -2
|
@@ -142,7 +142,13 @@ module Studio
|
|
|
142
142
|
# top. default_asset above stays the flat <img> for a mailer that has not
|
|
143
143
|
# adopted the layered banner.
|
|
144
144
|
background: "emails/magic-link-background.gif",
|
|
145
|
-
logo:
|
|
145
|
+
# NO logo: KEY — the same rule newsletter_subscribed above follows, and
|
|
146
|
+
# for the same reason. It seeded "emails/logo-horizontal.png" (the
|
|
147
|
+
# McRITCHIE STUDIO wordmark) into every host that registers this email
|
|
148
|
+
# without naming a mark, because register() merges on `logo.presence`
|
|
149
|
+
# and an omitted key INHERITS. On the SIGN-IN email, which is the most-
|
|
150
|
+
# sent email most of these apps have. Full reasoning under NO DEFAULT
|
|
151
|
+
# LOGO below; the guards are in test/integration/magic_link_logo_test.rb.
|
|
146
152
|
# The DEFAULT wording, overridable per app on /admin/emails. {name} is
|
|
147
153
|
# filled from whoever the mailer says the recipient is.
|
|
148
154
|
header: "Welcome {name}!",
|
|
@@ -199,10 +205,14 @@ module Studio
|
|
|
199
205
|
ASPECT_RATIO = 2.0
|
|
200
206
|
MAX_WIDTH = 1200
|
|
201
207
|
|
|
202
|
-
# NO DEFAULT LOGO, deliberately — on any route.
|
|
208
|
+
# NO DEFAULT LOGO, deliberately — on any route, for any entry.
|
|
203
209
|
#
|
|
204
|
-
# There is no DEFAULT_LOGO constant here, and
|
|
205
|
-
#
|
|
210
|
+
# There is no DEFAULT_LOGO constant here, and NO entry in STANDARD seeds a
|
|
211
|
+
# logo. That is now an invariant over the whole seed rather than a fact about
|
|
212
|
+
# two entries that happen to be clean — asserted in
|
|
213
|
+
# test/lib/studio/email_catalog_test.rb, which reads STANDARD and fails on any
|
|
214
|
+
# entry carrying a mark. The instance kept coming back; the rule is what
|
|
215
|
+
# stops it.
|
|
206
216
|
#
|
|
207
217
|
# The constant went first (DEFAULT_FOOTER_LOGO). It was
|
|
208
218
|
# emails/logo-horizontal.png — the white "McRITCHIE STUDIO" wordmark —
|
|
@@ -235,26 +245,47 @@ module Studio
|
|
|
235
245
|
# (`Studio::EmailCatalog.register("newsletter_subscribed", logo: "emails/our-mark.png")`,
|
|
236
246
|
# a path THAT APP ships) or on /admin/emails.
|
|
237
247
|
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
# the
|
|
247
|
-
#
|
|
248
|
-
#
|
|
248
|
+
# THE THIRD INSTANCE closed the question the first two left open: should
|
|
249
|
+
# STANDARD seed a logo AT ALL? It should not, and now none does.
|
|
250
|
+
#
|
|
251
|
+
# magic_link was the last holdout, and the sharpest — it seeded the wordmark
|
|
252
|
+
# onto the SIGN-IN email, the most-sent email most Studio apps have. Two
|
|
253
|
+
# narrow fixes (turf's magic_link, then newsletter_subscribed) had already
|
|
254
|
+
# been made, and the mechanism produced a third anyway. So the rule moved
|
|
255
|
+
# from the instance to the seed: a shared default may not carry anybody's
|
|
256
|
+
# identity, and the guard reads STANDARD rather than a list of keys, because
|
|
257
|
+
# the failure mode is the NEXT entry, written by someone who never read this.
|
|
258
|
+
#
|
|
259
|
+
# WHY THE ASYMMETRY DECIDES IT. A host that wanted the mark and lost it sees
|
|
260
|
+
# a missing logo — visible, and one line to restore. A host that never asked
|
|
261
|
+
# inherits another brand's identity into its own inbox — invisible, and the
|
|
262
|
+
# ALT TEXT AGREES WITH IT, because Studio::Banner sets logo_alt from
|
|
263
|
+
# Studio.app_name. turf's read "Turf Monster" over the Studio wordmark for
|
|
264
|
+
# weeks and no one reviewing that email could have seen it. Given one error
|
|
265
|
+
# to make, make the loud one.
|
|
266
|
+
#
|
|
267
|
+
# WHO PAID FOR THIS, named rather than left to be discovered: McRitchie
|
|
268
|
+
# Studio, the hub, registers magic_link to attach a preview builder and names
|
|
269
|
+
# no logo — so it INHERITED the mark it owns, and ships no copy of the file
|
|
270
|
+
# itself. It opts in with `logo: "emails/logo-horizontal.png"` on the
|
|
271
|
+
# register() call it already makes. The asset stays in this gem so that line
|
|
272
|
+
# resolves today; the hub shipping its own copy is the tidier end-state, as
|
|
273
|
+
# the mark is its identity and not the engine's. turf-monster names
|
|
274
|
+
# emails/turf-logo.png and was never affected.
|
|
275
|
+
#
|
|
276
|
+
# It was an UPGRADE TRAP rather than a live leak, which is the sharper shape
|
|
277
|
+
# and the reason this was worth fixing before anyone was hurt. The apps
|
|
278
|
+
# carrying no email initializer (moms-app, mcritchie-industries,
|
|
279
|
+
# acquisition-studio) are pinned far enough back — 0.32.1 / 0.32.2 / 0.13.1 —
|
|
280
|
+
# that this file does not exist in the gem they run, so they inherited
|
|
281
|
+
# nothing. The routine dependency bump that brought one forward would have
|
|
282
|
+
# put another brand's mark in its most-sent email, with no code change of its
|
|
283
|
+
# own and nobody looking for it.
|
|
249
284
|
#
|
|
250
285
|
# CHECK REACHABILITY AT THE PIN, never from main: `git ls-tree` at the tag the
|
|
251
286
|
# app resolves, or read the installed gem. "HEAD seeds it" plus "the app
|
|
252
287
|
# registers nothing" are both true and together say nothing about what that
|
|
253
288
|
# app RUNS — a wrong conclusion this very comment was first drafted with.
|
|
254
|
-
#
|
|
255
|
-
# Tracked, with the design question it raises — should STANDARD seed a logo at
|
|
256
|
-
# all, or should hosts opt in? — at /tasks/guard-wordmark-on-engine-bump.
|
|
257
|
-
# Fixing magic_link alone leaves the next entry free to repeat this.
|
|
258
289
|
|
|
259
290
|
# The footer band. Dark on purpose: it closes the white card, and a light
|
|
260
291
|
# sign-off floating under body copy reads as part of the message rather than
|
|
@@ -40,6 +40,33 @@
|
|
|
40
40
|
end
|
|
41
41
|
end %>
|
|
42
42
|
|
|
43
|
+
<%# Where the username and the avatar point — resolved ONCE, used by both.
|
|
44
|
+
|
|
45
|
+
A HOST'S OWN account_path WINS: an app that already has an account page keeps
|
|
46
|
+
it rather than being repointed at a thinner /profile by a routine dependency
|
|
47
|
+
bump. An app adopting /profile flips over by DELETING its account route, so
|
|
48
|
+
the last step of the migration is the deliberate one.
|
|
49
|
+
|
|
50
|
+
NO CONSUMER EXERCISES THAT BRANCH TODAY, and the comment here said otherwise
|
|
51
|
+
until 2026-08-14. turf-monster is the only app with an account_path, and it
|
|
52
|
+
ships its own app/views/components/_user_nav.html.erb — a host view SHADOWS
|
|
53
|
+
the engine's in this non-isolated engine, so turf never renders THIS file.
|
|
54
|
+
The rule is for apps that adopt /profile later.
|
|
55
|
+
|
|
56
|
+
nil when NEITHER exists, and then the name and avatar render as plain text.
|
|
57
|
+
They used to render `href="#"`, which was not a graceful degradation but a
|
|
58
|
+
dead link. Who that actually reached: mcritchie-industries and
|
|
59
|
+
acquisition-studio — the consumers rendering this partial rather than a fork
|
|
60
|
+
of it. A link that goes nowhere looks identical to one that works until it is
|
|
61
|
+
clicked, which is why nobody reported it.
|
|
62
|
+
%>
|
|
63
|
+
<% studio_nav_destination =
|
|
64
|
+
if defined?(account_path)
|
|
65
|
+
account_path
|
|
66
|
+
elsif defined?(profile_path)
|
|
67
|
+
profile_path
|
|
68
|
+
end %>
|
|
69
|
+
|
|
43
70
|
<div class="flex gap-2">
|
|
44
71
|
<% if logged_in? %>
|
|
45
72
|
<%# Left column: Div 1 + Div 2 stacked %>
|
|
@@ -61,9 +88,11 @@
|
|
|
61
88
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
62
89
|
<%= render "components/theme_toggle_morph" %>
|
|
63
90
|
</div>
|
|
64
|
-
<%
|
|
65
|
-
|
|
66
|
-
<%= current_user.display_name %>
|
|
91
|
+
<% nav_name_classes = "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" %>
|
|
92
|
+
<% if studio_nav_destination %>
|
|
93
|
+
<%= link_to current_user.display_name, studio_nav_destination, class: nav_name_classes %>
|
|
94
|
+
<% else %>
|
|
95
|
+
<span class="<%= nav_name_classes %>"><%= current_user.display_name %></span>
|
|
67
96
|
<% end %>
|
|
68
97
|
</div>
|
|
69
98
|
<% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
|
|
@@ -168,9 +197,14 @@
|
|
|
168
197
|
<% end %>
|
|
169
198
|
</div>
|
|
170
199
|
<%# Div 3 (Avatar): spans both rows %>
|
|
171
|
-
<%
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
<% if studio_nav_destination %>
|
|
201
|
+
<%= link_to studio_nav_destination, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
|
|
202
|
+
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
203
|
+
<% end %>
|
|
204
|
+
<% else %>
|
|
205
|
+
<span class="flex-shrink-0 flex items-center" x-bind:style="$store.devMode && 'background: lightgreen'">
|
|
206
|
+
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
207
|
+
</span>
|
|
174
208
|
<% end %>
|
|
175
209
|
<% else %>
|
|
176
210
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
@@ -312,8 +312,15 @@
|
|
|
312
312
|
</div>
|
|
313
313
|
|
|
314
314
|
<%# THE LOGO — three states, because a URL box can only express two.
|
|
315
|
-
Standard
|
|
316
|
-
Hidden is the deliberate "no logo" that blank cannot say.
|
|
315
|
+
Standard is the mark THIS APP registered, Custom is what it uploaded,
|
|
316
|
+
Hidden is the deliberate "no logo" that blank cannot say.
|
|
317
|
+
|
|
318
|
+
"Standard" is no longer a shared mark. No STANDARD entry seeds a logo
|
|
319
|
+
(the engine's wordmark riding the seed into a host's sign-in email
|
|
320
|
+
was the bug), so on an app that registered none, Standard and None
|
|
321
|
+
are the same picture — nothing. The note below says so, because a
|
|
322
|
+
control offering a choice that changes nothing is the failure this
|
|
323
|
+
page keeps producing. %>
|
|
317
324
|
<div>
|
|
318
325
|
<span class="text-sm text-muted block mb-1">Logo</span>
|
|
319
326
|
<div class="flex flex-wrap items-center gap-2">
|
|
@@ -336,6 +343,13 @@
|
|
|
336
343
|
<%= button_to "Back to the standard logo", admin_email_logo_path(@entry.key),
|
|
337
344
|
method: :patch, params: { remove: "1" },
|
|
338
345
|
class: "text-2xs text-muted underline underline-offset-2 mt-2" %>
|
|
346
|
+
<% elsif Studio::EmailCatalog.logo_url(@entry.key).blank? %>
|
|
347
|
+
<p class="text-2xs text-muted mt-2" data-logo-none-registered>
|
|
348
|
+
This app registers no mark for this email, so Standard shows none.
|
|
349
|
+
Upload one here, or name a file your app ships with
|
|
350
|
+
<code class="font-mono">logo:</code> in
|
|
351
|
+
<code class="font-mono">config/initializers/studio_emails.rb</code>.
|
|
352
|
+
</p>
|
|
339
353
|
<% end %>
|
|
340
354
|
</div>
|
|
341
355
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<%# Profile photo row — lifted from turf-monster's /account, which is the
|
|
2
|
+
interaction the operator wants standardized: click the avatar, hover reveals
|
|
3
|
+
an "Update" cap, pick a file, crop it square, and it saves immediately. No
|
|
4
|
+
Save button, no visible file field.
|
|
5
|
+
|
|
6
|
+
Locals: user (required).
|
|
7
|
+
|
|
8
|
+
HOW IT HANGS TOGETHER
|
|
9
|
+
imageUploadHost() the engine's crop-then-immediate-save x-data factory
|
|
10
|
+
(studio/modals/_image_upload). onFileSelected() reads
|
|
11
|
+
the picked file and opens the crop modal; the modal
|
|
12
|
+
dispatches the cropped Blob back on
|
|
13
|
+
'crop-photo-confirmed'; applyCrop() drops it into the
|
|
14
|
+
hidden form's file input and submits with a saving
|
|
15
|
+
card.
|
|
16
|
+
profileModals the PAGE-SCOPED Alpine store mounted by show.html.erb.
|
|
17
|
+
Not the shared "modals" store — see below.
|
|
18
|
+
profile_avatar_path its own endpoint, because an attachment param
|
|
19
|
+
submitted empty PURGES the attachment.
|
|
20
|
+
|
|
21
|
+
WHY THE PAGE-SCOPED STORE AND NOT `modals`. turf's copy opens on
|
|
22
|
+
Alpine.store('modals') — the app's shared modal host — which works there
|
|
23
|
+
because turf's layout mounts one and registers crop-photo in it. Two of the
|
|
24
|
+
five consumers (mcritchie-industries, moms-app) render NO shared host at all,
|
|
25
|
+
and the two that do would each have to add a crop-photo registration to their
|
|
26
|
+
layout before this row worked. An engine page cannot ask that. show.html.erb
|
|
27
|
+
mounts studio/modals/_scoped_host instead, so the page brings its own modals
|
|
28
|
+
and this row behaves identically in every app. /admin/emails already does
|
|
29
|
+
exactly this.
|
|
30
|
+
|
|
31
|
+
WHY onCropConfirmed AND NOT applyCrop DIRECTLY. turf's call site binds
|
|
32
|
+
`applyCrop($event.detail.blob)`, which predates the owner guard. Every
|
|
33
|
+
imageUploadHost on a page hears the same window event, so a page that later
|
|
34
|
+
grows a second uploader (a host row with its own image) would have both
|
|
35
|
+
hosts save the same crop. onCropConfirmed() checks the owner token first and
|
|
36
|
+
is a strict improvement at the same call cost.
|
|
37
|
+
|
|
38
|
+
ON THE INLINE PIXEL SIZES. Same reason turf gives: the exact circle geometry
|
|
39
|
+
is expressed inline rather than through w-24/h-24, because a size utility
|
|
40
|
+
only exists if something already emitted it into the compiled bundle. The
|
|
41
|
+
classes used here (group-hover:opacity-100, rounded-full, object-cover,
|
|
42
|
+
absolute inset-0) already ship in other engine partials.
|
|
43
|
+
%>
|
|
44
|
+
<div x-data="imageUploadHost({
|
|
45
|
+
store: 'profileModals',
|
|
46
|
+
aspectRatio: 1,
|
|
47
|
+
filename: 'avatar.png',
|
|
48
|
+
saving: 'Saving photo…',
|
|
49
|
+
toast: false
|
|
50
|
+
})"
|
|
51
|
+
@crop-photo-confirmed.window="onCropConfirmed($event.detail)"
|
|
52
|
+
class="flex flex-col items-center">
|
|
53
|
+
|
|
54
|
+
<%# Auto-submitted once a crop is confirmed. Hidden because the person never
|
|
55
|
+
touches it — applyCrop() writes the cropped File onto fileInput and calls
|
|
56
|
+
submitFormWithProgress on form. %>
|
|
57
|
+
<%= form_with url: profile_avatar_path, method: :patch, scope: :profile,
|
|
58
|
+
html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
|
|
59
|
+
<input type="file" name="profile[avatar]" x-ref="fileInput">
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<%# The real picker. Opened by the avatar button; never shown. %>
|
|
63
|
+
<input type="file" x-ref="filePicker" @change="onFileSelected($event)"
|
|
64
|
+
accept="<%= Studio::ProfileImage::ALLOWED_CONTENT_TYPES.join(",") %>" class="hidden">
|
|
65
|
+
|
|
66
|
+
<%# The clickable avatar. A BUTTON rather than turf's <div @click>: a div is
|
|
67
|
+
unreachable by keyboard and invisible to a screen reader, so the only way
|
|
68
|
+
to change your photo would be a mouse. Same visual, and it focuses. %>
|
|
69
|
+
<button type="button"
|
|
70
|
+
@click="$refs.filePicker.click()"
|
|
71
|
+
aria-label="Change your profile photo"
|
|
72
|
+
style="width: 96px; height: 96px;"
|
|
73
|
+
class="relative rounded-full cursor-pointer group overflow-hidden mb-3 flex-shrink-0 p-0 border-0 bg-transparent">
|
|
74
|
+
<% if user.avatar.attached? %>
|
|
75
|
+
<%= image_tag user.avatar, class: "rounded-full object-cover",
|
|
76
|
+
style: "width: 96px; height: 96px;", alt: user.display_name %>
|
|
77
|
+
<% else %>
|
|
78
|
+
<div class="rounded-full flex items-center justify-center font-bold text-white text-3xl"
|
|
79
|
+
style="width: 96px; height: 96px; background-color: <%= user.avatar_color %>">
|
|
80
|
+
<%= user.avatar_initials %>
|
|
81
|
+
</div>
|
|
82
|
+
<% end %>
|
|
83
|
+
|
|
84
|
+
<%# The hover cap. group-hover reveals it on pointer; focus-within is the
|
|
85
|
+
keyboard equivalent, so a tabbing user sees the same affordance. %>
|
|
86
|
+
<span class="absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity duration-200"
|
|
87
|
+
style="background: rgba(0,0,0,0.5)">
|
|
88
|
+
<span class="text-white text-xs font-bold">Update</span>
|
|
89
|
+
</span>
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
<div class="text-xl font-bold text-heading"><%= user.display_name %></div>
|
|
93
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# First-name row.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
This row only renders when the host's user model actually answers
|
|
6
|
+
`first_name` — Studio::ProfileSections drops it otherwise, so an app that has
|
|
7
|
+
not installed the standard profile columns gets a page without this row
|
|
8
|
+
rather than a NoMethodError on every visit. Nothing here needs to re-check.
|
|
9
|
+
|
|
10
|
+
The same column is written by the onboarding step
|
|
11
|
+
(Studio::OnboardingController#first_name); this is the surface for changing it
|
|
12
|
+
later.
|
|
13
|
+
%>
|
|
14
|
+
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
15
|
+
data: { turbo: false }, class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
16
|
+
<div class="flex-1 min-w-0" style="min-width: 12rem;">
|
|
17
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
|
|
18
|
+
<%= form.text_field :first_name,
|
|
19
|
+
value: user.first_name,
|
|
20
|
+
maxlength: Studio::FIRST_NAME_MAX_LENGTH,
|
|
21
|
+
autocomplete: "given-name",
|
|
22
|
+
placeholder: "What should we call you?",
|
|
23
|
+
class: "input-field" %>
|
|
24
|
+
</div>
|
|
25
|
+
<%= form.submit "Save", class: "btn btn-primary" %>
|
|
26
|
+
<% end %>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%# Google account row — lifted from turf-monster's /account Identities card.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
Two states. LINKED: a tick, "Connected via Google", and an Unlink button
|
|
6
|
+
behind a confirm. NOT LINKED: the branded Connect button, which POSTs to
|
|
7
|
+
OmniAuth's own /auth/google_oauth2 — the engine does not draw that path, the
|
|
8
|
+
middleware owns it, which is why this is a literal string and not a helper.
|
|
9
|
+
|
|
10
|
+
The row only renders when the host's user model answers provider AND uid
|
|
11
|
+
(Studio::ProfileSections drops it otherwise), so nothing here re-checks.
|
|
12
|
+
|
|
13
|
+
ON THE PREDICATE: turf's view asks `@user.google_connected?`, a method only
|
|
14
|
+
turf defines. The engine cannot call that — it reads provider/uid itself
|
|
15
|
+
through Studio::OauthIdentity, which also matches BOTH spellings that appear
|
|
16
|
+
in the wild (`google_oauth2` from the OmniAuth strategy, `google` from
|
|
17
|
+
Studio.auth_methods).
|
|
18
|
+
%>
|
|
19
|
+
<% linked = Studio::OauthIdentity.google_linked?(user) %>
|
|
20
|
+
|
|
21
|
+
<% if linked %>
|
|
22
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
23
|
+
<div class="flex items-center gap-3">
|
|
24
|
+
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
25
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
26
|
+
</svg>
|
|
27
|
+
<span class="text-body">Connected via Google</span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<%# The button is rendered either way; when unlinking would orphan the
|
|
31
|
+
account it is DISABLED with the reason beside it, rather than hidden.
|
|
32
|
+
A control that vanishes teaches nothing — the person cannot tell whether
|
|
33
|
+
the feature is missing or their account is special. The server refuses
|
|
34
|
+
this case regardless (ProfilesController#unlink_google); this is the
|
|
35
|
+
explanation, not the enforcement. %>
|
|
36
|
+
<% orphans = Studio::OauthIdentity.unlink_orphans_account?(user) %>
|
|
37
|
+
<% if orphans %>
|
|
38
|
+
<div class="text-right">
|
|
39
|
+
<button type="button" disabled
|
|
40
|
+
class="btn btn-danger btn-sm opacity-50 cursor-not-allowed"
|
|
41
|
+
title="Google is currently the only way to sign in">Unlink</button>
|
|
42
|
+
<p class="text-muted text-xs mt-1">Add an email address first — Google is your only sign-in.</p>
|
|
43
|
+
</div>
|
|
44
|
+
<% else %>
|
|
45
|
+
<%= button_to "Unlink", profile_unlink_google_path, method: :delete,
|
|
46
|
+
class: "btn btn-danger btn-sm",
|
|
47
|
+
data: { turbo_confirm: "Unlink your Google account?" } %>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|
|
50
|
+
<% else %>
|
|
51
|
+
<%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
|
|
52
|
+
class: "btn btn-google gap-3 w-full" do %>
|
|
53
|
+
<%= render "components/google_logo" %>
|
|
54
|
+
Link Google Account
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%# The shared /profile page.
|
|
2
|
+
|
|
3
|
+
A bare content wrapper, not a layout: it renders inside the host's
|
|
4
|
+
application layout and inherits that app's navbar, theme and flash. Same
|
|
5
|
+
shape as /admin/style and /admin/emails.
|
|
6
|
+
|
|
7
|
+
The page is nothing but its sections. @profile_sections is already resolved
|
|
8
|
+
and filtered by the controller (Studio.profile_sections_for) — rows the host
|
|
9
|
+
cannot serve, and admin rows for non-admins, are gone before we get here — so
|
|
10
|
+
this template makes no decisions of its own beyond an empty state.
|
|
11
|
+
%>
|
|
12
|
+
<% content_for(:title, "Profile") %>
|
|
13
|
+
|
|
14
|
+
<%# Does any resolved row need the crop/saving modals? A row declares
|
|
15
|
+
`modals: true` and the page mounts them once, here.
|
|
16
|
+
|
|
17
|
+
The page owns its modal host DELIBERATELY. The obvious alternative — render
|
|
18
|
+
"studio/modals/host" and let the app supply it — fails in both directions:
|
|
19
|
+
mcritchie-industries and moms-app render no shared host at all, so there
|
|
20
|
+
would be nowhere to open a modal from; and mcritchie-studio and turf-monster
|
|
21
|
+
each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
|
|
22
|
+
engine's in this non-isolated engine, so the page would silently get their
|
|
23
|
+
fork and its registrations. studio/modals/_scoped_host is unforked in every
|
|
24
|
+
app. /admin/emails reached the same conclusion first.
|
|
25
|
+
|
|
26
|
+
Mounted once per page rather than once per row: two hosts sharing a store
|
|
27
|
+
name would register the same modal ids twice and render duplicate cards. %>
|
|
28
|
+
<% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
|
|
29
|
+
|
|
30
|
+
<% if profile_needs_modals %>
|
|
31
|
+
<%# cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
|
|
32
|
+
factories. Loaded only when a row can actually open the cropper, which is
|
|
33
|
+
the whole reason this is a partial and not a global include. %>
|
|
34
|
+
<%= render "studio/cropper_assets" %>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<div class="max-w-2xl mx-auto py-8">
|
|
38
|
+
<h1 class="text-2xl font-extrabold text-heading mb-8">Profile</h1>
|
|
39
|
+
|
|
40
|
+
<% if @profile_sections.blank? %>
|
|
41
|
+
<%# Reachable, and worth rendering honestly rather than as a blank page: an
|
|
42
|
+
app whose user model serves none of the declared rows (no avatar
|
|
43
|
+
attachment, no first_name column) lands here until it installs the
|
|
44
|
+
standard profile columns. %>
|
|
45
|
+
<%= render "components/empty_state",
|
|
46
|
+
message: "Nothing to edit yet",
|
|
47
|
+
detail: "This app has not enabled any profile fields." %>
|
|
48
|
+
<% else %>
|
|
49
|
+
<div class="space-y-6">
|
|
50
|
+
<% @profile_sections.each do |section| %>
|
|
51
|
+
<section class="card p-6" data-profile-section="<%= section[:key] %>">
|
|
52
|
+
<% if section[:title].present? %>
|
|
53
|
+
<h2 class="text-lg font-bold text-heading mb-4"><%= section[:title] %></h2>
|
|
54
|
+
<% end %>
|
|
55
|
+
<%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
|
|
56
|
+
</section>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<% if profile_needs_modals %>
|
|
63
|
+
<%# Optional chaining on current() is load-bearing: the outer template unmounts
|
|
64
|
+
one tick AFTER the stack empties, so a bare .id throws on every close. %>
|
|
65
|
+
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
66
|
+
<template x-if="$store.profileModals.current()?.id === 'crop-photo'">
|
|
67
|
+
<div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
|
|
68
|
+
</template>
|
|
69
|
+
<template x-if="$store.profileModals.current()?.id === 'saving'">
|
|
70
|
+
<div><%= render "studio/modals/saving", store: "profileModals" %></div>
|
|
71
|
+
</template>
|
|
72
|
+
<% end %>
|
|
73
|
+
<% end %>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The OAuth identity bound to an account — is Google linked, and is it safe to
|
|
4
|
+
# unlink it?
|
|
5
|
+
#
|
|
6
|
+
# Pure Ruby and duck-typed, like Studio::ProfileImage: it takes anything that
|
|
7
|
+
# answers `provider` / `uid` / `email`, so the rules are unit-testable without a
|
|
8
|
+
# users table, and a host whose model has none of those columns is simply
|
|
9
|
+
# reported as "not linked" rather than raising.
|
|
10
|
+
#
|
|
11
|
+
# THE ORPHAN GUARD IS THE REASON THIS FILE EXISTS. turf-monster's
|
|
12
|
+
# AccountsController#unlink_google is one line — `update!(provider: nil, uid: nil)`
|
|
13
|
+
# — with no check at all. For an account whose ONLY sign-in is Google (blank
|
|
14
|
+
# email, so no magic link; no wallet; no password) that button silently locks
|
|
15
|
+
# someone out of their own account, and the label just says "Unlink". It is safe
|
|
16
|
+
# in turf today only because turf's users happen to carry an email; that is a
|
|
17
|
+
# property of turf's data, not of the code, and the engine ships to apps whose
|
|
18
|
+
# data it has never seen.
|
|
19
|
+
#
|
|
20
|
+
# So the engine asks first: after this unlink, is there still a way back in?
|
|
21
|
+
module Studio
|
|
22
|
+
module OauthIdentity
|
|
23
|
+
# Both spellings appear across the ecosystem: `google_oauth2` is the OmniAuth
|
|
24
|
+
# strategy name that lands in users.provider, and `google` is what
|
|
25
|
+
# Studio.auth_methods calls the same thing. Matching both means a host that
|
|
26
|
+
# stored either is read correctly rather than reported as unlinked.
|
|
27
|
+
GOOGLE_PROVIDERS = %w[google google_oauth2].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def google_linked?(user)
|
|
32
|
+
return false unless user.respond_to?(:provider) && user.respond_to?(:uid)
|
|
33
|
+
|
|
34
|
+
GOOGLE_PROVIDERS.include?(user.provider.to_s) && user.uid.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every way this account could sign in if Google were gone. Returns symbols
|
|
38
|
+
# so a caller can name what is left in a message rather than just refusing.
|
|
39
|
+
#
|
|
40
|
+
# Gated on Studio.auth_methods, not just on the column: an app that has an
|
|
41
|
+
# email column but does not offer magic-link sign-in cannot use it to get
|
|
42
|
+
# back in, and counting it would be exactly the wrong answer.
|
|
43
|
+
def remaining_sign_ins(user, auth_methods: Studio.auth_methods)
|
|
44
|
+
methods = Array(auth_methods).map(&:to_sym)
|
|
45
|
+
remaining = []
|
|
46
|
+
|
|
47
|
+
remaining << :magic_link if methods.include?(:magic_link) && present?(user, :email)
|
|
48
|
+
remaining << :wallet if methods.include?(:wallet) && wallet_present?(user)
|
|
49
|
+
# `Studio.password_login_available?` — NOT a bare password_digest check.
|
|
50
|
+
# A digest can be a FOSSIL: turf-monster removed `has_secure_password` and
|
|
51
|
+
# kept the column, so rows still carry digests no code can authenticate
|
|
52
|
+
# against. Counting one as a way back in is a false positive in the
|
|
53
|
+
# dangerous direction — it would permit an unlink that orphans the account.
|
|
54
|
+
# The engine already ships the correct composite predicate
|
|
55
|
+
# (auth_method?(:password) && the User answering `authenticate`).
|
|
56
|
+
remaining << :password if methods.include?(:password) &&
|
|
57
|
+
Studio.password_login_available? &&
|
|
58
|
+
present?(user, :password_digest)
|
|
59
|
+
|
|
60
|
+
remaining
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The question the controller actually asks before unlinking.
|
|
64
|
+
def unlink_orphans_account?(user, auth_methods: Studio.auth_methods)
|
|
65
|
+
remaining_sign_ins(user, auth_methods: auth_methods).empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def present?(user, attribute)
|
|
69
|
+
user.respond_to?(attribute) && user.public_send(attribute).present?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ONLY the explicitly configured wallet column — no fallback to a
|
|
73
|
+
# conventional name, deliberately.
|
|
74
|
+
#
|
|
75
|
+
# A convention-guessed reader is a false positive waiting to happen, and
|
|
76
|
+
# turf-monster is the live example: its `User#solana_address` returns
|
|
77
|
+
# `web3_solana_address || web2_solana_address`, and only the WEB3 address can
|
|
78
|
+
# actually sign in (SolanaSessionsController verifies a wallet signature; the
|
|
79
|
+
# web2 address is a custodial account with no signer). Guessing that reader
|
|
80
|
+
# would count a custodial address as a way back in and permit an unlink that
|
|
81
|
+
# orphans the account.
|
|
82
|
+
#
|
|
83
|
+
# So an app that has not named its signing-wallet column is treated as having
|
|
84
|
+
# no wallet sign-in. That errs toward REFUSING an unlink, which is the safe
|
|
85
|
+
# direction: the cost is an occasional refusal the operator can resolve by
|
|
86
|
+
# configuring `Studio.wallet_address_method`; the cost of the other direction
|
|
87
|
+
# is someone locked out of their account.
|
|
88
|
+
def wallet_present?(user)
|
|
89
|
+
configured = Studio.wallet_address_method
|
|
90
|
+
return false if configured.blank?
|
|
91
|
+
|
|
92
|
+
present?(user, configured)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The house rule for "is this upload an acceptable profile picture?" — one
|
|
4
|
+
# allowlist, one size cap, in one place.
|
|
5
|
+
#
|
|
6
|
+
# Pure Ruby and duck-typed on purpose: it takes anything answering
|
|
7
|
+
# `content_type` and `size`, which is what an ActionDispatch::Http::UploadedFile
|
|
8
|
+
# answers, so the rule is unit-testable without Rails, a request, or a users
|
|
9
|
+
# table.
|
|
10
|
+
#
|
|
11
|
+
# LIFTED FROM turf-monster, where this lived as ApplicationController#valid_image?
|
|
12
|
+
# with an IMAGE_UPLOAD_TYPES constant beside it. Same allowlist, same 8 MB cap —
|
|
13
|
+
# the values are turf's, deliberately, because they are the ones that have been
|
|
14
|
+
# in front of real uploads. What changes is that the second and third app no
|
|
15
|
+
# longer have to re-derive them.
|
|
16
|
+
#
|
|
17
|
+
# ON THE ALLOWLIST BEING AN ALLOWLIST: an avatar is attacker-supplied bytes that
|
|
18
|
+
# the app then serves back to other people. Naming the three formats we accept is
|
|
19
|
+
# what keeps an SVG — which is a script host, not a picture — from becoming a
|
|
20
|
+
# profile photo. Do not widen this to a `start_with?("image/")` check.
|
|
21
|
+
module Studio
|
|
22
|
+
module ProfileImage
|
|
23
|
+
ALLOWED_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze
|
|
24
|
+
|
|
25
|
+
# 8 MB. Phone cameras clear this comfortably; it is a bound on abuse, not on
|
|
26
|
+
# the user's actual photo.
|
|
27
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
# A human sentence for the rejection path. Kept next to the rule so the two
|
|
30
|
+
# cannot drift — a message naming the wrong limit is worse than none.
|
|
31
|
+
MESSAGE = "Use a PNG, JPG, or WebP under 8 MB."
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def acceptable?(file)
|
|
36
|
+
return false unless file.respond_to?(:content_type) && file.respond_to?(:size)
|
|
37
|
+
return false unless ALLOWED_CONTENT_TYPES.include?(file.content_type)
|
|
38
|
+
|
|
39
|
+
size = file.size
|
|
40
|
+
size.is_a?(Numeric) && size.positive? && size <= MAX_BYTES
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|