studio-engine 0.47.2 → 0.49.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 +234 -0
- data/app/controllers/studio/onboarding_controller.rb +5 -1
- data/app/controllers/studio/profiles_controller.rb +257 -0
- data/app/mailers/studio/profile_mailer.rb +55 -0
- data/app/models/concerns/studio/user_profile.rb +111 -0
- data/app/services/studio/email_catalog.rb +21 -0
- data/app/views/components/_user_nav.html.erb +40 -6
- data/app/views/studio/profile_mailer/email_change_notification.html.erb +30 -0
- data/app/views/studio/profile_mailer/email_change_notification.text.erb +11 -0
- data/app/views/studio/profiles/_avatar_section.html.erb +93 -0
- data/app/views/studio/profiles/_email_section.html.erb +55 -0
- data/app/views/studio/profiles/_first_name_section.html.erb +42 -0
- data/app/views/studio/profiles/_google_section.html.erb +56 -0
- data/app/views/studio/profiles/show.html.erb +81 -0
- data/lib/studio/oauth_identity.rb +95 -0
- data/lib/studio/profile_image.rb +43 -0
- data/lib/studio/profile_sections.rb +176 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +91 -0
- metadata +15 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Studio
|
|
6
|
+
# The four things every Studio app's User has had to define for itself: how to
|
|
7
|
+
# name a person on screen, and how to draw them when they have no picture.
|
|
8
|
+
#
|
|
9
|
+
# WHY THIS EXISTS. The engine's own components/_avatar partial calls
|
|
10
|
+
# `display_name`, `avatar_initials` and `avatar_color` — and the engine has
|
|
11
|
+
# never provided any of them. Every consumer wrote its own; mcritchie-industries'
|
|
12
|
+
# user.rb even carries a comment explaining that it must, because the engine's
|
|
13
|
+
# nav renders the partial. Three copies, and by 2026-08-14 they had drifted:
|
|
14
|
+
#
|
|
15
|
+
# mcritchie-studio name → email prefix (capitalized) → wallet → "anon"
|
|
16
|
+
# mcritchie-industries name → email prefix (raw) → "User"
|
|
17
|
+
# turf-monster username → name → email prefix (cap) → wallet → "anon"
|
|
18
|
+
#
|
|
19
|
+
# Same intent, three answers, and two different words for the same empty state.
|
|
20
|
+
# That is the drift a concern removes.
|
|
21
|
+
#
|
|
22
|
+
# ON THE MERGED CHAIN. It is turf-monster's — the richest of the three — with
|
|
23
|
+
# `first_name` folded in after `name`, because /profile now lets people set that
|
|
24
|
+
# field and a name someone just typed should be the name they see. Apps missing
|
|
25
|
+
# a link in the chain simply skip it; the chain is respond_to?-guarded end to
|
|
26
|
+
# end, so mcritchie-industries' eight-column users table walks it without
|
|
27
|
+
# raising. One consequence worth stating rather than burying: an MI user with no
|
|
28
|
+
# name now falls back to "anon" instead of "User", and an email-derived name is
|
|
29
|
+
# capitalized. That is MI adopting the house standard, and its adoption task
|
|
30
|
+
# owns the change.
|
|
31
|
+
#
|
|
32
|
+
# EVERY METHOD IS OVERRIDABLE. These are plain instance methods from an included
|
|
33
|
+
# module, so a host that defines its own `display_name` in the class body wins
|
|
34
|
+
# outright. Standardizing the default is the goal; forbidding an app from having
|
|
35
|
+
# an opinion is not.
|
|
36
|
+
module UserProfile
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
# The house palette for initial-circle backgrounds. Identical in all three
|
|
40
|
+
# apps today, which is the clearest possible signal it belonged here.
|
|
41
|
+
AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
|
|
42
|
+
|
|
43
|
+
# What to call this person on screen.
|
|
44
|
+
def display_name
|
|
45
|
+
studio_profile_username.presence ||
|
|
46
|
+
studio_profile_attr(:name).presence ||
|
|
47
|
+
studio_profile_attr(:first_name).presence ||
|
|
48
|
+
studio_profile_email_prefix.presence ||
|
|
49
|
+
studio_profile_wallet.presence ||
|
|
50
|
+
"anon"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One character for the initials circle. Deliberately NOT derived from
|
|
54
|
+
# display_name: that chain ends in a wallet address or the word "anon", and
|
|
55
|
+
# "a" for every anonymous account is worse than a neutral mark.
|
|
56
|
+
def avatar_initials
|
|
57
|
+
source = studio_profile_username.presence ||
|
|
58
|
+
studio_profile_attr(:name).presence ||
|
|
59
|
+
studio_profile_attr(:first_name).presence ||
|
|
60
|
+
studio_profile_email_prefix.presence
|
|
61
|
+
|
|
62
|
+
# `source[0]`, not ActiveSupport's `String#first`: this module is included
|
|
63
|
+
# into a host model, and nothing here should depend on which core_ext that
|
|
64
|
+
# host happens to have loaded. Plain Ruby costs nothing and cannot vanish.
|
|
65
|
+
source.presence.to_s[0]&.upcase || "?"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A stable colour for this account — same person, same circle, every render.
|
|
69
|
+
# Hashed rather than random for exactly that reason, and keyed off identity
|
|
70
|
+
# fields so it survives a display-name change.
|
|
71
|
+
def avatar_color
|
|
72
|
+
key = studio_profile_username.presence ||
|
|
73
|
+
studio_profile_attr(:name).presence ||
|
|
74
|
+
studio_profile_attr(:email).presence ||
|
|
75
|
+
id.to_s
|
|
76
|
+
|
|
77
|
+
AVATAR_COLORS[Digest::MD5.hexdigest(key.to_s).hex % AVATAR_COLORS.size]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Read an attribute only if this host's model actually has it. The whole
|
|
83
|
+
# tolerance of the chain lives in this one method.
|
|
84
|
+
def studio_profile_attr(attribute)
|
|
85
|
+
return nil unless respond_to?(attribute)
|
|
86
|
+
|
|
87
|
+
public_send(attribute)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `username` is turf-monster's on-chain handle and does not exist in the hub
|
|
91
|
+
# apps. It leads the chain where it exists because it is what that app's
|
|
92
|
+
# people call each other.
|
|
93
|
+
def studio_profile_username
|
|
94
|
+
studio_profile_attr(:username)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def studio_profile_email_prefix
|
|
98
|
+
email = studio_profile_attr(:email)
|
|
99
|
+
return nil if email.blank?
|
|
100
|
+
|
|
101
|
+
email.to_s.split("@").first.to_s.capitalize
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Defers to the host's own truncation rather than reformatting an address
|
|
105
|
+
# here — mcritchie-studio and turf-monster both define `truncated_solana`,
|
|
106
|
+
# and turf's has to pick between a web2 and a web3 address to do it.
|
|
107
|
+
def studio_profile_wallet
|
|
108
|
+
studio_profile_attr(:truncated_solana)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -193,6 +193,27 @@ module Studio
|
|
|
193
193
|
# inherited email with no preview is a row on every app's manager that
|
|
194
194
|
# cannot be looked at.
|
|
195
195
|
preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: "email_change_notification",
|
|
199
|
+
label: "Email change — heads up",
|
|
200
|
+
description: "Sent to the OLD address after a change lands, so an unauthorized change is visible rather than silent.",
|
|
201
|
+
# Reuses the shipped email-change artwork. The asset is named for a
|
|
202
|
+
# confirmation email that no longer exists; the picture is right for the
|
|
203
|
+
# subject either way, and renaming a shipped asset is its own migration.
|
|
204
|
+
default_asset: "emails/email-change-confirmation.gif",
|
|
205
|
+
header: "Your email was changed",
|
|
206
|
+
header_fallback: "Your email was changed",
|
|
207
|
+
subtext: "a heads-up from {app}",
|
|
208
|
+
subject: "Your {app} email was changed",
|
|
209
|
+
body: "The email address on your {app} account was just changed. " \
|
|
210
|
+
"If you did not do this, contact us straight away — this message " \
|
|
211
|
+
"was sent to your previous address on purpose.",
|
|
212
|
+
# No button: there is nothing to click. The point is the notice itself.
|
|
213
|
+
supports_cta: false,
|
|
214
|
+
preview: lambda {
|
|
215
|
+
Studio::ProfileMailer.email_change_notification(nil, "old@example.com", "new@example.com")
|
|
216
|
+
}
|
|
196
217
|
}
|
|
197
218
|
].freeze
|
|
198
219
|
|
|
@@ -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? %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<%# Body only — branded_mailer supplies the banner + card.
|
|
2
|
+
|
|
3
|
+
NO BUTTON, deliberately: there is nothing to click. This email exists so an
|
|
4
|
+
unauthorised change is VISIBLE to the person losing the account, and the
|
|
5
|
+
catalogue entry sets supports_cta: false to match. Adding a CTA here would
|
|
6
|
+
invite someone to click their way somewhere from a message that may itself
|
|
7
|
+
be the first sign of a compromise.
|
|
8
|
+
%>
|
|
9
|
+
<%
|
|
10
|
+
body = @body.presence || Studio::EmailCatalog.body(:email_change_notification)
|
|
11
|
+
%>
|
|
12
|
+
<h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your email was changed</h1>
|
|
13
|
+
|
|
14
|
+
<% if body.present? %>
|
|
15
|
+
<%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px;">
|
|
19
|
+
<tr>
|
|
20
|
+
<td style="padding:14px 16px;background:#f4f6f3;border-radius:10px;font-size:15px;line-height:1.6;color:#3f4a3c;">
|
|
21
|
+
<strong style="color:#2f3a2c;">Was:</strong> <%= @old_email %><br>
|
|
22
|
+
<strong style="color:#2f3a2c;">Now:</strong> <%= @new_email %>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
25
|
+
</table>
|
|
26
|
+
|
|
27
|
+
<p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
|
|
28
|
+
This message was sent to <strong style="color:#2f3a2c;"><%= @old_email %></strong> — your previous
|
|
29
|
+
address — on purpose, so a change you did not make cannot happen quietly.
|
|
30
|
+
</p>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
The email address on your <%= @app_name %> account was just changed.
|
|
2
|
+
|
|
3
|
+
Was: <%= @old_email %>
|
|
4
|
+
Now: <%= @new_email %>
|
|
5
|
+
|
|
6
|
+
If you did not do this, contact us straight away.
|
|
7
|
+
|
|
8
|
+
This message was sent to <%= @old_email %> — your previous address — on purpose,
|
|
9
|
+
so a change you did not make cannot happen quietly.
|
|
10
|
+
|
|
11
|
+
— <%= @app_name %>
|
|
@@ -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,55 @@
|
|
|
1
|
+
<%# Email row.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
DIRECT CHANGE from any signed-in session (operator's call, 2026-08-14). The
|
|
6
|
+
earlier build mailed a confirmation link to the current address and applied
|
|
7
|
+
the change only when someone clicked it; the session is the authority now.
|
|
8
|
+
The row therefore saves what you type, and the copy says so plainly rather
|
|
9
|
+
than implying a pending step that no longer exists.
|
|
10
|
+
|
|
11
|
+
THE GOOGLE EXCEPTION. An account with a linked Google identity cannot change
|
|
12
|
+
its email here — Google is the authoritative source for that address, and
|
|
13
|
+
letting the two drift means the next OAuth sign-in either re-links to a
|
|
14
|
+
stranger's row or cannot find its own. The locked branch renders NO form at
|
|
15
|
+
all — there is nothing to type into and nothing to submit — and
|
|
16
|
+
ProfilesController#email refuses the request independently anyway, because a
|
|
17
|
+
missing form is a courtesy and anyone can POST.
|
|
18
|
+
|
|
19
|
+
Gated on a real delta for the same reason the name row is — an empty field,
|
|
20
|
+
or the address you already have, is not a change.
|
|
21
|
+
%>
|
|
22
|
+
<% locked = Studio::OauthIdentity.google_linked?(user) %>
|
|
23
|
+
<% current = user.email.to_s.strip %>
|
|
24
|
+
|
|
25
|
+
<% if locked %>
|
|
26
|
+
<p class="text-sm text-secondary mb-1">Current address</p>
|
|
27
|
+
<p class="text-heading font-semibold mb-3 break-all"><%= user.email %></p>
|
|
28
|
+
<div class="rounded-lg p-3 flex items-start gap-2" style="background: var(--color-surface-alt);">
|
|
29
|
+
<span aria-hidden="true">🔒</span>
|
|
30
|
+
<p class="text-muted text-xs">
|
|
31
|
+
This comes from your linked Google account. Unlink Google below if you want to change it.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<%= form_with url: profile_email_path, method: :patch, scope: :profile,
|
|
36
|
+
data: { turbo: false },
|
|
37
|
+
html: { "x-data": "{ current: #{current.downcase.to_json}, value: #{current.to_json} }" },
|
|
38
|
+
class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
39
|
+
<div class="flex-1 min-w-0" style="min-width: 14rem;">
|
|
40
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_email">Email</label>
|
|
41
|
+
<%= form.email_field :email, value: user.email, autocomplete: "email",
|
|
42
|
+
placeholder: "you@example.com", class: "input-field",
|
|
43
|
+
"x-model": "value" %>
|
|
44
|
+
</div>
|
|
45
|
+
<%= form.submit "Update",
|
|
46
|
+
class: "btn btn-primary",
|
|
47
|
+
":disabled": "value.trim() === '' || value.trim().toLowerCase() === current",
|
|
48
|
+
":class": "(value.trim() === '' || value.trim().toLowerCase() === current) && 'opacity-50 cursor-not-allowed'" %>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% if current.present? %>
|
|
51
|
+
<p class="text-muted text-xs mt-2">
|
|
52
|
+
We'll let <span class="font-semibold"><%= user.email %></span> know it changed, and sign out your other devices.
|
|
53
|
+
</p>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# First-name row.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
PREFILLED, with the Update button to the right of the input and DISABLED
|
|
6
|
+
until the value actually differs (operator's call, 2026-08-14). A blank field
|
|
7
|
+
made you retype a name you had already set just to see what it was, and an
|
|
8
|
+
always-live Save invited a no-op write that flashed "Name updated." over a
|
|
9
|
+
change nobody made.
|
|
10
|
+
|
|
11
|
+
The delta is computed against the value the server rendered, and both sides
|
|
12
|
+
are trimmed — leading or trailing whitespace is not a change, and the
|
|
13
|
+
controller trims on the way in too, so the button agrees with what the server
|
|
14
|
+
would actually do.
|
|
15
|
+
|
|
16
|
+
NO-JS FALLBACK: the button carries no `disabled` attribute in the markup;
|
|
17
|
+
Alpine adds it on init. A page whose JS never ran therefore gets a working,
|
|
18
|
+
always-enabled Save rather than a control it can never turn on — the server
|
|
19
|
+
re-checks anyway.
|
|
20
|
+
|
|
21
|
+
This row only renders when the host's user model answers `first_name`
|
|
22
|
+
(Studio::ProfileSections drops it otherwise), so nothing here re-checks.
|
|
23
|
+
%>
|
|
24
|
+
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
25
|
+
data: { turbo: false },
|
|
26
|
+
html: { "x-data": "{ initial: #{user.first_name.to_s.strip.to_json}, value: #{user.first_name.to_s.strip.to_json} }" },
|
|
27
|
+
class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
28
|
+
<div class="flex-1 min-w-0" style="min-width: 12rem;">
|
|
29
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
|
|
30
|
+
<%= form.text_field :first_name,
|
|
31
|
+
value: user.first_name,
|
|
32
|
+
maxlength: Studio::FIRST_NAME_MAX_LENGTH,
|
|
33
|
+
autocomplete: "given-name",
|
|
34
|
+
placeholder: "What should we call you?",
|
|
35
|
+
class: "input-field",
|
|
36
|
+
"x-model": "value" %>
|
|
37
|
+
</div>
|
|
38
|
+
<%= form.submit "Update",
|
|
39
|
+
class: "btn btn-primary",
|
|
40
|
+
":disabled": "value.trim() === initial.trim()",
|
|
41
|
+
":class": "value.trim() === initial.trim() && 'opacity-50 cursor-not-allowed'" %>
|
|
42
|
+
<% 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,81 @@
|
|
|
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
|
+
ONE CARD, rows inside it (operator's call, 2026-08-14). Each row used to be
|
|
8
|
+
its own card, which read as four unrelated settings pages stacked up rather
|
|
9
|
+
than one profile. Rows are separated by a hairline instead, so the page has a
|
|
10
|
+
single edge and the eye runs down one column.
|
|
11
|
+
|
|
12
|
+
The page is still nothing but its sections. @profile_sections is already
|
|
13
|
+
resolved and filtered by the controller (Studio.profile_sections_for) — rows
|
|
14
|
+
the host cannot serve, and admin rows for non-admins, are gone before we get
|
|
15
|
+
here — so this template makes no decisions of its own beyond the empty state.
|
|
16
|
+
%>
|
|
17
|
+
<% content_for(:title, "Profile") %>
|
|
18
|
+
|
|
19
|
+
<%# Does any resolved row need the crop/saving modals? A row declares
|
|
20
|
+
`modals: true` and the page mounts them once, here.
|
|
21
|
+
|
|
22
|
+
The page owns its modal host DELIBERATELY. The obvious alternative — render
|
|
23
|
+
"studio/modals/host" and let the app supply it — fails in both directions:
|
|
24
|
+
mcritchie-industries and moms-app render no shared host at all, so there
|
|
25
|
+
would be nowhere to open a modal from; and mcritchie-studio and turf-monster
|
|
26
|
+
each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
|
|
27
|
+
engine's in this non-isolated engine, so the page would silently get their
|
|
28
|
+
fork and its registrations. studio/modals/_scoped_host is unforked in every
|
|
29
|
+
app. /admin/emails reached the same conclusion first. %>
|
|
30
|
+
<%# Mounted ONCE per page, not once per row. Two hosts sharing a store name
|
|
31
|
+
would register the same modal ids twice and render duplicate cards, so the
|
|
32
|
+
page owns the host and the rows only ask for it. %>
|
|
33
|
+
<% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
|
|
34
|
+
|
|
35
|
+
<% if profile_needs_modals %>
|
|
36
|
+
<%# cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
|
|
37
|
+
factories. Loaded only when a row can actually open the cropper, which is
|
|
38
|
+
the whole reason this is a partial and not a global include. %>
|
|
39
|
+
<%= render "studio/cropper_assets" %>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<div class="max-w-2xl mx-auto py-8">
|
|
43
|
+
<h1 class="text-2xl font-extrabold text-heading mb-8">Profile</h1>
|
|
44
|
+
|
|
45
|
+
<% if @profile_sections.blank? %>
|
|
46
|
+
<%# Reachable, and worth rendering honestly rather than as a blank page: an
|
|
47
|
+
app whose user model serves none of the declared rows (no avatar
|
|
48
|
+
attachment, no first_name column) lands here until it installs the
|
|
49
|
+
standard profile columns. %>
|
|
50
|
+
<%= render "components/empty_state",
|
|
51
|
+
message: "Nothing to edit yet",
|
|
52
|
+
detail: "This app has not enabled any profile fields." %>
|
|
53
|
+
<% else %>
|
|
54
|
+
<div class="card p-0 overflow-hidden">
|
|
55
|
+
<% @profile_sections.each_with_index do |section, index| %>
|
|
56
|
+
<%# The divider is a TOP border on every row but the first, so a row can
|
|
57
|
+
be added or dropped anywhere without leaving a trailing rule. %>
|
|
58
|
+
<section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
|
|
59
|
+
data-profile-section="<%= section[:key] %>">
|
|
60
|
+
<% if section[:title].present? %>
|
|
61
|
+
<h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
|
|
62
|
+
<% end %>
|
|
63
|
+
<%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<% if profile_needs_modals %>
|
|
71
|
+
<%# Optional chaining on current() is load-bearing: the outer template unmounts
|
|
72
|
+
one tick AFTER the stack empties, so a bare .id throws on every close. %>
|
|
73
|
+
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
74
|
+
<template x-if="$store.profileModals.current()?.id === 'crop-photo'">
|
|
75
|
+
<div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
|
|
76
|
+
</template>
|
|
77
|
+
<template x-if="$store.profileModals.current()?.id === 'saving'">
|
|
78
|
+
<div><%= render "studio/modals/saving", store: "profileModals" %></div>
|
|
79
|
+
</template>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% end %>
|