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.
@@ -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
- <% account_link = defined?(account_path) ? account_path : "#" %>
65
- <%= link_to account_link, class: "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" do %>
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
- <% account_link = defined?(account_path) ? account_path : "#" %>
172
- <%= link_to account_link, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
173
- <%= render "components/avatar", user: current_user, size: "nav" %>
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 %>