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,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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Resolves Studio.profile_sections — the rows that make up the shared /profile
|
|
4
|
+
# page — for a given view context. Pure Ruby (no Rails dependency) so the unit
|
|
5
|
+
# suite exercises the resolution rules without booting the dummy app.
|
|
6
|
+
#
|
|
7
|
+
# THE SHAPE IS DELIBERATELY THE SIDEBAR'S. Studio.sidebar_sections already taught
|
|
8
|
+
# this codebase one way to let a host declare page furniture: a static Array or a
|
|
9
|
+
# callable that receives the view, symbolized on the way out, with sections the
|
|
10
|
+
# viewer may not see dropped before render. Reusing that shape means a host that
|
|
11
|
+
# has declared a sidebar already knows how to declare a profile.
|
|
12
|
+
#
|
|
13
|
+
# What a section looks like:
|
|
14
|
+
#
|
|
15
|
+
# { key: :avatar, title: "Profile photo", partial: "studio/profiles/avatar_section",
|
|
16
|
+
# locals: { ... }, requires: :avatar, admin: false }
|
|
17
|
+
#
|
|
18
|
+
# key — stable identifier. A host removes or replaces a default by key,
|
|
19
|
+
# so reordering the defaults never breaks a host's override.
|
|
20
|
+
# title — the row's heading.
|
|
21
|
+
# partial — the partial to render. Host partials are ordinary app views.
|
|
22
|
+
# locals — optional Hash passed to the partial.
|
|
23
|
+
# requires — optional attribute (or Array of them) the CURRENT USER must
|
|
24
|
+
# respond to for the row to render. See below.
|
|
25
|
+
# admin — admin-only row, same rule as the sidebar's.
|
|
26
|
+
# if — optional callable. The row renders only when it returns truthy.
|
|
27
|
+
# Called with the view when it takes an argument, without when it
|
|
28
|
+
# does not. This is the APP-CAPABILITY gate, distinct from
|
|
29
|
+
# `requires:` which is the MODEL gate — and the two really are
|
|
30
|
+
# different questions. Every consumer's users table carries
|
|
31
|
+
# `provider` and `uid`, so `requires:` alone selected the whole
|
|
32
|
+
# fleet for the Google row; mcritchie-industries has those columns
|
|
33
|
+
# AND `auth_methods = %i[magic_link]` with no omniauth gem at all,
|
|
34
|
+
# so it would have rendered a "Link Google Account" button leading
|
|
35
|
+
# nowhere. Having a column is not the same as offering the feature.
|
|
36
|
+
# modals — the row opens the shared crop/saving modals. The PAGE mounts them
|
|
37
|
+
# once (studio/modals/_scoped_host on a "profileModals" store) when
|
|
38
|
+
# any resolved row asks for them, so a row never has to know whether
|
|
39
|
+
# the host app renders a modal host of its own. Two of the five
|
|
40
|
+
# consumers render none at all, and the two that do ship a FORK of
|
|
41
|
+
# the engine's shared host that would shadow it.
|
|
42
|
+
#
|
|
43
|
+
# ON `requires` — this is the whole reason the registry exists rather than a
|
|
44
|
+
# hardcoded page. The consuming apps do NOT agree on their users table:
|
|
45
|
+
# mcritchie-industries has eight columns and no `first_name`; turf-monster has
|
|
46
|
+
# forty. A shared page that assumed a column would raise NoMethodError on every
|
|
47
|
+
# signed-in request in the app that lacked it — the same failure mode
|
|
48
|
+
# Studio.first_name_outstanding? already guards against with respond_to?.
|
|
49
|
+
#
|
|
50
|
+
# So a row DECLARES what it needs and is dropped when the host cannot serve it.
|
|
51
|
+
# mcritchie-industries gets a working profile page with an avatar row and no name
|
|
52
|
+
# row until it installs the standard-profile-columns migration, at which point
|
|
53
|
+
# the row appears with no code change. Silence, not a 500.
|
|
54
|
+
module Studio
|
|
55
|
+
module ProfileSections
|
|
56
|
+
# The page every consumer gets for free. Iteration one is deliberately two
|
|
57
|
+
# rows: the picture and the name. Everything else the account-standardization
|
|
58
|
+
# program lifts (email change, identities, preferences) arrives as further
|
|
59
|
+
# defaults, and a host that has declared its own list keeps working because
|
|
60
|
+
# it composes against `Studio.default_profile_sections` rather than a literal.
|
|
61
|
+
DEFAULTS = [
|
|
62
|
+
{ key: :avatar, title: "Profile photo",
|
|
63
|
+
partial: "studio/profiles/avatar_section", requires: :avatar, modals: true },
|
|
64
|
+
{ key: :first_name, title: "Your name",
|
|
65
|
+
partial: "studio/profiles/first_name_section", requires: :first_name },
|
|
66
|
+
{ key: :email, title: "Email",
|
|
67
|
+
partial: "studio/profiles/email_section", requires: :email },
|
|
68
|
+
# Gated on the app OFFERING Google, not merely on having the columns —
|
|
69
|
+
# the same question app/views/sessions/new.html.erb already asks before
|
|
70
|
+
# drawing this identical button on the login page.
|
|
71
|
+
{ key: :google, title: "Google account",
|
|
72
|
+
partial: "studio/profiles/google_section", requires: %i[provider uid],
|
|
73
|
+
if: -> { Studio.auth_method?(:google) } }
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# A fresh, mutable copy every call. Hosts compose with `+` and sometimes
|
|
79
|
+
# `reject`, and handing out the frozen literal would let one host's edit
|
|
80
|
+
# leak into the next request's page.
|
|
81
|
+
def defaults
|
|
82
|
+
DEFAULTS.map { |section| section.dup }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Declared may be nil (the host has said nothing — it gets the defaults), an
|
|
86
|
+
# Array, or a callable receiving the view context.
|
|
87
|
+
#
|
|
88
|
+
# `nil` meaning "the defaults" rather than "no sections" is the load-bearing
|
|
89
|
+
# choice here: it is what makes a brand-new app's profile page work with an
|
|
90
|
+
# empty initializer, which is the entire point of standardizing this.
|
|
91
|
+
def resolve(declared, view)
|
|
92
|
+
sections = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
93
|
+
sections = defaults if sections.nil?
|
|
94
|
+
|
|
95
|
+
admin = view.respond_to?(:admin?) && view.admin?
|
|
96
|
+
user = view.respond_to?(:current_user) ? view.current_user : nil
|
|
97
|
+
|
|
98
|
+
Array(sections)
|
|
99
|
+
.map { |section| symbolize(section) }
|
|
100
|
+
.reject { |section| section[:admin] && !admin }
|
|
101
|
+
.select { |section| enabled?(section[:if], view) }
|
|
102
|
+
.select { |section| served_by?(user, section[:requires]) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The app-capability gate. No `if:` means always enabled. The callable takes
|
|
106
|
+
# the view when it wants one and nothing when it does not, so a host can
|
|
107
|
+
# write either `-> { Studio.feature?(:x) }` or `->(view) { view.admin? }`.
|
|
108
|
+
def enabled?(condition, view)
|
|
109
|
+
return true if condition.nil?
|
|
110
|
+
|
|
111
|
+
# A Symbol/String names a method on the VIEW — Rails' own
|
|
112
|
+
# `before_action ..., if: :method_name` convention, and therefore the most
|
|
113
|
+
# natural thing a host will write here.
|
|
114
|
+
#
|
|
115
|
+
# It is handled explicitly because the alternative FAILS OPEN: a Symbol
|
|
116
|
+
# does not answer `call`, so the earlier `return !!condition` coerced
|
|
117
|
+
# `:some_predicate` to true and rendered the row unconditionally — a gate
|
|
118
|
+
# that silently does nothing, in the same permissive direction as the bug
|
|
119
|
+
# this whole `if:` key was added to fix. A host would have had no signal.
|
|
120
|
+
if condition.is_a?(Symbol) || condition.is_a?(String)
|
|
121
|
+
unless view.respond_to?(condition)
|
|
122
|
+
# SILENT would repeat the bug this key was added to fix. The old
|
|
123
|
+
# coercion gave the host no signal; dropping the row without one only
|
|
124
|
+
# moves the silence somewhere safer. A typo'd gate now says so.
|
|
125
|
+
warn_gate("profile_sections: `if: #{condition.inspect}` names a method the view " \
|
|
126
|
+
"does not answer — the row was dropped. Check the spelling.")
|
|
127
|
+
return false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Arity-aware, matching the lambda branch below: a predicate written as
|
|
131
|
+
# `def visible?(view)` is as natural as one written without an argument,
|
|
132
|
+
# and calling it wrong raises ArgumentError — which surfaces as a 500 on
|
|
133
|
+
# /profile rather than as a dropped row.
|
|
134
|
+
method = view.method(condition)
|
|
135
|
+
return !!(method.arity.zero? ? view.public_send(condition) : view.public_send(condition, view))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return !!condition unless condition.respond_to?(:call)
|
|
139
|
+
|
|
140
|
+
!!(condition.arity.zero? ? condition.call : condition.call(view))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Can this host's user model serve the row? No requirement means yes — a row
|
|
144
|
+
# that reads nothing off the user (a static explainer, a link out) is always
|
|
145
|
+
# served. A nil user means we are rendering for nobody, and nothing is served.
|
|
146
|
+
def served_by?(user, requires)
|
|
147
|
+
needed = Array(requires).compact
|
|
148
|
+
return true if needed.empty?
|
|
149
|
+
return false if user.nil?
|
|
150
|
+
|
|
151
|
+
needed.all? { |attribute| user.respond_to?(attribute) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Pure Ruby: this file loads without Rails, so it cannot assume a logger.
|
|
155
|
+
def warn_gate(message)
|
|
156
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
157
|
+
Rails.logger.warn(message)
|
|
158
|
+
else
|
|
159
|
+
Kernel.warn(message)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def symbolize(hash)
|
|
164
|
+
out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
165
|
+
|
|
166
|
+
# `key` is an IDENTIFIER, so its VALUE symbolizes too — unlike every other
|
|
167
|
+
# field, whose value is data. A host composing by key writes
|
|
168
|
+
# `reject { |s| s[:key] == :avatar }`, and that silently matches nothing if
|
|
169
|
+
# a section declared with string keys kept `"avatar"` as a String. Removing
|
|
170
|
+
# a standard row is the documented seam; it must not depend on which
|
|
171
|
+
# spelling the host happened to use.
|
|
172
|
+
out[:key] = out[:key].to_sym if out[:key].respond_to?(:to_sym)
|
|
173
|
+
out
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -7,6 +7,9 @@ require "studio/environment_banner"
|
|
|
7
7
|
require "studio/theme_resolver"
|
|
8
8
|
require "studio/ui_primitives"
|
|
9
9
|
require "studio/sidebar_sections"
|
|
10
|
+
require "studio/profile_sections"
|
|
11
|
+
require "studio/profile_image"
|
|
12
|
+
require "studio/oauth_identity"
|
|
10
13
|
require "studio/username_generator"
|
|
11
14
|
require "studio/s3"
|
|
12
15
|
require "studio/image_cache"
|
|
@@ -83,6 +86,49 @@ module Studio
|
|
|
83
86
|
# end
|
|
84
87
|
mattr_accessor :sidebar_sections, default: []
|
|
85
88
|
|
|
89
|
+
# ---- The shared profile page (/profile) ----
|
|
90
|
+
# The rows that make up /profile. `nil` — the default — means "the engine's
|
|
91
|
+
# standard page", NOT "no rows": a brand-new app with an empty initializer gets
|
|
92
|
+
# a working profile page, which is the entire point of standardizing it.
|
|
93
|
+
#
|
|
94
|
+
# Declare an Array (or a callable receiving the view) to customize. Compose
|
|
95
|
+
# against Studio.default_profile_sections rather than a literal, so a later
|
|
96
|
+
# engine release that adds a standard row delivers it to you:
|
|
97
|
+
#
|
|
98
|
+
# Studio.configure do |config|
|
|
99
|
+
# config.profile_sections = ->(view) {
|
|
100
|
+
# Studio.default_profile_sections +
|
|
101
|
+
# [ { key: :wallet, title: "Identities", partial: "profiles/wallet" } ]
|
|
102
|
+
# }
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# Drop a standard row by key instead of restating the list:
|
|
106
|
+
#
|
|
107
|
+
# config.profile_sections = Studio.default_profile_sections.reject { |s| s[:key] == :avatar }
|
|
108
|
+
#
|
|
109
|
+
# Shape and resolution rules: lib/studio/profile_sections.rb. A row may declare
|
|
110
|
+
# `requires:` — an attribute the current user must respond to — and is dropped
|
|
111
|
+
# when this host's model does not have it, so the page tolerates the fact that
|
|
112
|
+
# the consuming apps' users tables genuinely disagree.
|
|
113
|
+
mattr_accessor :profile_sections, default: nil
|
|
114
|
+
|
|
115
|
+
# Whether Studio.routes draws /profile (Studio::ProfilesController).
|
|
116
|
+
#
|
|
117
|
+
# ON BY DEFAULT, and it is worth saying why this one can be when
|
|
118
|
+
# draw_admin_emails_routes and draw_onboarding_routes could not: those two
|
|
119
|
+
# claimed helper names a consumer ALREADY OWNED, so drawing them raised
|
|
120
|
+
# `Invalid route name, already in use` while that app's routes.rb loaded and
|
|
121
|
+
# took down its entire route set. `profile` is claimed by none of the five
|
|
122
|
+
# consumers — mcritchie-studio, mcritchie-industries, turf-monster, moms-app
|
|
123
|
+
# and acquisition-studio were each checked (2026-08-14) — and turf-monster's
|
|
124
|
+
# nearest names are complete_profile_account_path / save_profile_account_path,
|
|
125
|
+
# which do not collide.
|
|
126
|
+
#
|
|
127
|
+
# That is exactly why the shared page is /profile and not /account: turf owns
|
|
128
|
+
# account_path, and a shared /account could never be default-on. An app that
|
|
129
|
+
# wants the page gone still sets this false.
|
|
130
|
+
mattr_accessor :draw_profile_routes, default: true
|
|
131
|
+
|
|
86
132
|
# How long a freshly minted magic link stays live.
|
|
87
133
|
mattr_accessor :magic_link_ttl, default: 15.minutes
|
|
88
134
|
|
|
@@ -179,6 +225,17 @@ module Studio
|
|
|
179
225
|
# later session may ask again. That is the whole reason this is not a column.
|
|
180
226
|
FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
|
|
181
227
|
|
|
228
|
+
# How long a first name may be. ONE constant because users.first_name is
|
|
229
|
+
# written from TWO surfaces — the onboarding step (seconds after signup) and
|
|
230
|
+
# /profile (any time after) — and rendered by a third, the profile form's
|
|
231
|
+
# maxlength. Two independently-correct caps that disagreed would let onboarding
|
|
232
|
+
# accept a name /profile then refused to save, a bug with no obvious owner.
|
|
233
|
+
#
|
|
234
|
+
# Keeping it here rather than on either controller also keeps the VIEW off a
|
|
235
|
+
# controller constant: the form needs the number, and a view reaching into
|
|
236
|
+
# Studio::ProfilesController to get it would couple the two for no reason.
|
|
237
|
+
FIRST_NAME_MAX_LENGTH = 40
|
|
238
|
+
|
|
182
239
|
# The shared rule for "does this account still owe us a first name?" — the one
|
|
183
240
|
# piece of onboarding logic every app agrees on. Hosts compose it into their own
|
|
184
241
|
# flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
|
|
@@ -545,6 +602,21 @@ module Studio
|
|
|
545
602
|
SidebarSections.resolve(sidebar_sections, view)
|
|
546
603
|
end
|
|
547
604
|
|
|
605
|
+
# The engine's standard /profile rows. Hosts compose against this rather than
|
|
606
|
+
# restating a literal list, so a later release that adds a standard row
|
|
607
|
+
# delivers it to every app that used the seam as intended.
|
|
608
|
+
def self.default_profile_sections
|
|
609
|
+
ProfileSections.defaults
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Profile rows resolved for a view context: a callable config is called with
|
|
613
|
+
# the view, keys symbolize, admin-only rows drop for non-admin viewers, and
|
|
614
|
+
# rows this host's user model cannot serve drop entirely. `nil` config resolves
|
|
615
|
+
# to the standard page.
|
|
616
|
+
def self.profile_sections_for(view)
|
|
617
|
+
ProfileSections.resolve(profile_sections, view)
|
|
618
|
+
end
|
|
619
|
+
|
|
548
620
|
def self.env_truthy?(value)
|
|
549
621
|
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
550
622
|
end
|
|
@@ -602,6 +674,25 @@ module Studio
|
|
|
602
674
|
post "auth/solana/verify", to: "solana_sessions#verify", as: :solana_verify
|
|
603
675
|
end
|
|
604
676
|
|
|
677
|
+
# The shared profile page. ON by default — unlike /admin/emails and the
|
|
678
|
+
# onboarding pair, `profile` is claimed by no consumer, so drawing it
|
|
679
|
+
# cannot take an app's route set down. See Studio.draw_profile_routes.
|
|
680
|
+
#
|
|
681
|
+
# Avatar is its own PATCH rather than a field on #update: an attachment
|
|
682
|
+
# param submitted empty PURGES the attachment, so a single form carrying
|
|
683
|
+
# both would delete someone's photo every time they edited their name.
|
|
684
|
+
if Studio.draw_profile_routes
|
|
685
|
+
get "profile", to: "studio/profiles#show", as: :profile
|
|
686
|
+
patch "profile", to: "studio/profiles#update"
|
|
687
|
+
patch "profile/avatar", to: "studio/profiles#avatar", as: :profile_avatar
|
|
688
|
+
# DELETE, because unlinking removes an identity. Linking is not drawn
|
|
689
|
+
# here: it is OmniAuth's own /auth/:provider, which the middleware owns.
|
|
690
|
+
patch "profile/email", to: "studio/profiles#email", as: :profile_email
|
|
691
|
+
delete "profile/google", to: "studio/profiles#unlink_google",
|
|
692
|
+
as: :profile_unlink_google
|
|
693
|
+
|
|
694
|
+
end
|
|
695
|
+
|
|
605
696
|
resources :error_logs, only: [:index, :show]
|
|
606
697
|
|
|
607
698
|
# Admin
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.49.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -204,6 +204,7 @@ files:
|
|
|
204
204
|
- app/controllers/studio/local_reviews_controller.rb
|
|
205
205
|
- app/controllers/studio/models_controller.rb
|
|
206
206
|
- app/controllers/studio/onboarding_controller.rb
|
|
207
|
+
- app/controllers/studio/profiles_controller.rb
|
|
207
208
|
- app/controllers/style_controller.rb
|
|
208
209
|
- app/controllers/theme_settings_controller.rb
|
|
209
210
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
@@ -215,10 +216,12 @@ files:
|
|
|
215
216
|
- app/jobs/studio/email_delivery_job.rb
|
|
216
217
|
- app/mailers/application_mailer.rb
|
|
217
218
|
- app/mailers/studio/newsletter_mailer.rb
|
|
219
|
+
- app/mailers/studio/profile_mailer.rb
|
|
218
220
|
- app/mailers/user_mailer.rb
|
|
219
221
|
- app/models/concerns/sluggable.rb
|
|
220
222
|
- app/models/concerns/studio/board/rankable.rb
|
|
221
223
|
- app/models/concerns/studio/broadcastable.rb
|
|
224
|
+
- app/models/concerns/studio/user_profile.rb
|
|
222
225
|
- app/models/current.rb
|
|
223
226
|
- app/models/error_log.rb
|
|
224
227
|
- app/models/image_cache.rb
|
|
@@ -333,6 +336,13 @@ files:
|
|
|
333
336
|
- app/views/studio/models/show.html.erb
|
|
334
337
|
- app/views/studio/newsletter_mailer/subscribed.html.erb
|
|
335
338
|
- app/views/studio/newsletter_mailer/subscribed.text.erb
|
|
339
|
+
- app/views/studio/profile_mailer/email_change_notification.html.erb
|
|
340
|
+
- app/views/studio/profile_mailer/email_change_notification.text.erb
|
|
341
|
+
- app/views/studio/profiles/_avatar_section.html.erb
|
|
342
|
+
- app/views/studio/profiles/_email_section.html.erb
|
|
343
|
+
- app/views/studio/profiles/_first_name_section.html.erb
|
|
344
|
+
- app/views/studio/profiles/_google_section.html.erb
|
|
345
|
+
- app/views/studio/profiles/show.html.erb
|
|
336
346
|
- app/views/style/_modal_specimen.html.erb
|
|
337
347
|
- app/views/style/_modals.html.erb
|
|
338
348
|
- app/views/style/_specimen.html.erb
|
|
@@ -373,6 +383,9 @@ files:
|
|
|
373
383
|
- lib/studio/link_token.rb
|
|
374
384
|
- lib/studio/log_rotation.rb
|
|
375
385
|
- lib/studio/mail_transport.rb
|
|
386
|
+
- lib/studio/oauth_identity.rb
|
|
387
|
+
- lib/studio/profile_image.rb
|
|
388
|
+
- lib/studio/profile_sections.rb
|
|
376
389
|
- lib/studio/redis.rb
|
|
377
390
|
- lib/studio/s3.rb
|
|
378
391
|
- lib/studio/sidebar_sections.rb
|