studio-engine 0.5.9 → 0.5.10
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 +21 -0
- data/README.md +44 -1
- data/app/assets/images/resend-favicon.png +0 -0
- data/app/assets/images/ses-favicon.png +0 -0
- data/app/controllers/concerns/studio/admin_models.rb +122 -0
- data/app/controllers/concerns/studio/impersonation.rb +90 -0
- data/app/helpers/studio_email_delivery_helper.rb +41 -1
- data/app/views/studio/admin_models/_arenas_table.html.erb +43 -0
- data/app/views/studio/admin_models/_teams_table.html.erb +112 -0
- data/app/views/studio/admin_models/index.html.erb +31 -0
- data/app/views/studio/admin_models/show.html.erb +37 -0
- data/app/views/studio/banners/_app_banner.html.erb +35 -0
- data/app/views/studio/banners/_button.html.erb +89 -0
- data/app/views/studio/banners/_dev_mode_button.html.erb +4 -0
- data/app/views/studio/banners/_email_status_button.html.erb +23 -0
- data/app/views/studio/banners/_environment.html.erb +18 -0
- data/app/views/studio/banners/_impersonation.html.erb +24 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +8 -0
- metadata +17 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0817243bffbcd834fd5a3d8f593a420b12d8fba691c48337b68e6661ebbe5d7c'
|
|
4
|
+
data.tar.gz: 5328f4a61db1c6c8965a0291bf0573a1e71b9e83b29e8eafe0a974b5208a0487
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 642c7e82c5576ed3565091922473f6da39d3ab5388d5affb5d6c4a7dda9be7f037dda006e5ead66f61ba711701ad0fbd7cb0038227d102e7fc962a22c0fb888d
|
|
7
|
+
data.tar.gz: 8457efd67516bc2bcb42bc440cf6ff628c9153be16d0064bb8d3998f21d4c9a4f96e6b15a6978c08d8aa97d0f965a94a368f1a5df734589d4a08b61af60a2e7b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## v0.5.10 (2026-06-15)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`Studio::AdminModels`** shared controller concern plus shared admin model
|
|
11
|
+
index/show shells and teams/arenas table partials. Consumer apps define their
|
|
12
|
+
model registry and scopes locally, while shared pagination, team sorting,
|
|
13
|
+
sport emoji display, team JSON modal payloads, and model page framing live in
|
|
14
|
+
the engine.
|
|
15
|
+
- **Shared operator primitives** under `studio/banners/*` for non-production
|
|
16
|
+
environment banners, the shared banner button, Dev Mode controls, email
|
|
17
|
+
connector status, and admin impersonation banners. Consumers can render
|
|
18
|
+
`studio/banners/environment` and `studio/banners/impersonation`.
|
|
19
|
+
- **`Studio::Impersonation`** opt-in concern for Act As session conventions:
|
|
20
|
+
`true_user`, `impersonated_user`, `impersonating?`,
|
|
21
|
+
`start_impersonation_session`, and `clear_impersonation_session`. Consumer
|
|
22
|
+
apps still own authorization, audit logging, routes, and app-specific safety
|
|
23
|
+
rules.
|
|
24
|
+
- **`StudioEmailDeliveryHelper#email_delivery_banner_details`** returns the
|
|
25
|
+
structured connector, provider icon, send/capture state, and tooltip used by
|
|
26
|
+
the shared email status button.
|
|
27
|
+
|
|
7
28
|
## v0.5.9 (2026-06-14)
|
|
8
29
|
|
|
9
30
|
### Added
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Shared Rails engine for McRitchie apps. Provides authentication, error handling,
|
|
|
11
11
|
gem "studio-engine", "~> 0.5"
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Then `bundle install`. The current release is **v0.5.
|
|
14
|
+
Then `bundle install`. The current release is **v0.5.10**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
|
|
15
15
|
|
|
16
16
|
> Published to RubyGems as of v0.4.0 (2026-05-17). New installs should use the RubyGems form, which the consumer Rails apps (`mcritchie-studio`, `turf-monster`) already use.
|
|
17
17
|
|
|
@@ -20,6 +20,7 @@ Then `bundle install`. The current release is **v0.5.9**; see [`CHANGELOG.md`](.
|
|
|
20
20
|
- **Authentication**: Passwordless magic-link auth, optional password auth, Google OAuth via OmniAuth, Solana wallet sign-in, and optional one-way SSO patterns
|
|
21
21
|
- **Error handling**: `Studio::ErrorHandling` concern with `rescue_and_log`, `ErrorLog` model with `capture!`, error log viewer at `/error_logs`
|
|
22
22
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
23
|
+
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
|
|
23
24
|
- **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
|
|
24
25
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
25
26
|
|
|
@@ -68,6 +69,48 @@ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link r
|
|
|
68
69
|
|
|
69
70
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
70
71
|
|
|
72
|
+
## Non-Production Banners
|
|
73
|
+
|
|
74
|
+
Consumer layouts can render the shared environment banner inside their sticky
|
|
75
|
+
header:
|
|
76
|
+
|
|
77
|
+
```erb
|
|
78
|
+
<%= render "studio/banners/environment", devnet: false %>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The environment banner includes:
|
|
82
|
+
|
|
83
|
+
- a Dev Mode toggle button backed by `Alpine.store("devMode")`
|
|
84
|
+
- an Email status button that links to `/_studio/local_emails`
|
|
85
|
+
- a send/capture signal plus SES/Resend/unknown connector icon
|
|
86
|
+
|
|
87
|
+
Apps with admin Act As / impersonation state can render the matching banner
|
|
88
|
+
with their own users and return route:
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%= render "studio/banners/impersonation",
|
|
92
|
+
impersonated_user: current_user,
|
|
93
|
+
admin_user: true_user,
|
|
94
|
+
stop_path: admin_stop_impersonating_path %>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The engine also provides an optional `Studio::Impersonation` concern for the
|
|
98
|
+
session convention:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
class ApplicationController < ActionController::Base
|
|
102
|
+
include Studio::ErrorHandling
|
|
103
|
+
include Studio::Impersonation
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The concern adds `true_user`, `impersonated_user`, `impersonating?`,
|
|
108
|
+
`start_impersonation_session(target_user, actor:)`, and
|
|
109
|
+
`clear_impersonation_session`. Consumer apps still own the authorization rule,
|
|
110
|
+
audit log, enter/exit controller actions, and any app-specific safeguards such
|
|
111
|
+
as binding session-token checks to `true_user` or disabling wallet-only
|
|
112
|
+
privileges while impersonating.
|
|
113
|
+
|
|
71
114
|
## Overriding Views
|
|
72
115
|
|
|
73
116
|
This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
module AdminModels
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
PREVIEW_LIMIT = 10
|
|
6
|
+
PER_PAGE = 25
|
|
7
|
+
TEAM_SORTS = {
|
|
8
|
+
"team" => "LOWER(teams.name)",
|
|
9
|
+
"sport" => "LOWER(COALESCE(teams.sport, ''))",
|
|
10
|
+
"league" => "LOWER(COALESCE(teams.league, ''))"
|
|
11
|
+
}.freeze
|
|
12
|
+
SHARED_TABLE_KEYS = %w[teams arenas].freeze
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
before_action :set_model_config, only: :show
|
|
16
|
+
helper_method :team_sort_url, :team_sort_indicator, :team_sport_emoji,
|
|
17
|
+
:team_record_json, :admin_models_table_partial
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def index
|
|
21
|
+
@sections = admin_model_configs.map do |key, config|
|
|
22
|
+
scope = admin_model_scope_for(key)
|
|
23
|
+
{
|
|
24
|
+
key: key,
|
|
25
|
+
label: config.fetch(:label),
|
|
26
|
+
description: config.fetch(:description),
|
|
27
|
+
count: scope.count,
|
|
28
|
+
records: scope.limit(PREVIEW_LIMIT)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
render "studio/admin_models/index"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def show
|
|
36
|
+
@page = [params[:page].to_i, 1].max
|
|
37
|
+
@total_count = @scope.count
|
|
38
|
+
@total_pages = [(@total_count.to_f / PER_PAGE).ceil, 1].max
|
|
39
|
+
@records = @scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
|
|
40
|
+
|
|
41
|
+
render "studio/admin_models/show"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def set_model_config
|
|
47
|
+
@key = params[:key].to_s
|
|
48
|
+
@config = admin_model_configs.fetch(@key) { raise ActiveRecord::RecordNotFound }
|
|
49
|
+
@scope = admin_model_scope_for(@key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def admin_model_configs
|
|
53
|
+
self.class::MODELS
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def admin_model_scope_for(_key)
|
|
57
|
+
raise NotImplementedError, "#{self.class.name} must implement #admin_model_scope_for"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def team_sort_key
|
|
61
|
+
TEAM_SORTS.key?(params[:sort].to_s) ? params[:sort].to_s : "team"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def team_sort_direction
|
|
65
|
+
params[:direction].to_s == "desc" ? "desc" : "asc"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def team_sort_order
|
|
69
|
+
direction = team_sort_direction == "desc" ? "DESC" : "ASC"
|
|
70
|
+
expression = TEAM_SORTS.fetch(team_sort_key)
|
|
71
|
+
Arel.sql("#{expression} #{direction}, LOWER(teams.name) ASC")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def team_sort_url(key)
|
|
75
|
+
query = request.query_parameters.merge(
|
|
76
|
+
"sort" => key,
|
|
77
|
+
"direction" => team_sort_key == key && team_sort_direction == "asc" ? "desc" : "asc"
|
|
78
|
+
)
|
|
79
|
+
query.delete("page")
|
|
80
|
+
|
|
81
|
+
"#{request.path}?#{query.to_query}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def team_sort_indicator(key)
|
|
85
|
+
return "" unless team_sort_key == key
|
|
86
|
+
|
|
87
|
+
team_sort_direction
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def team_sport_emoji(team)
|
|
91
|
+
case team.sport.to_s
|
|
92
|
+
when "football" then "🏈"
|
|
93
|
+
when "soccer" then "⚽"
|
|
94
|
+
when "basketball" then "🏀"
|
|
95
|
+
when "baseball" then "⚾"
|
|
96
|
+
when "hockey" then "🏒"
|
|
97
|
+
else "•"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def team_record_json(team)
|
|
102
|
+
payload = team.attributes.merge("mascot" => team.mascot)
|
|
103
|
+
payload["home_arena"] = team.home_arena&.attributes if team.respond_to?(:home_arena)
|
|
104
|
+
|
|
105
|
+
JSON.pretty_generate(payload)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def admin_models_table_partial(key)
|
|
109
|
+
shared_table_keys = if self.class.const_defined?(:SHARED_TABLE_KEYS, false)
|
|
110
|
+
self.class::SHARED_TABLE_KEYS
|
|
111
|
+
else
|
|
112
|
+
SHARED_TABLE_KEYS
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if shared_table_keys.map(&:to_s).include?(key.to_s)
|
|
116
|
+
"studio/admin_models/#{key}_table"
|
|
117
|
+
else
|
|
118
|
+
"admin/models/#{key}_table"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Impersonation
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
if respond_to?(:helper_method)
|
|
9
|
+
helper_method :true_user, :impersonated_user, :impersonating?, :impersonation_started_at
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# The real session owner. While acting as another user, this remains the
|
|
16
|
+
# admin/operator account whose session token should be verified.
|
|
17
|
+
def true_user
|
|
18
|
+
return @true_user if defined?(@true_user)
|
|
19
|
+
|
|
20
|
+
@true_user = User.find_by(id: session[Studio.session_key])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The currently visible user. Consumers with extra wallet or privilege state
|
|
24
|
+
# can override this and still call true_user/impersonating? from the concern.
|
|
25
|
+
def current_user
|
|
26
|
+
return @current_user if defined?(@current_user)
|
|
27
|
+
|
|
28
|
+
@current_user = impersonating? ? impersonated_user : true_user
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def impersonated_user
|
|
32
|
+
return @impersonated_user if defined?(@impersonated_user)
|
|
33
|
+
|
|
34
|
+
@impersonated_user = User.find_by(id: session[Studio.impersonation_target_session_key])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def impersonating?
|
|
38
|
+
return @impersonating if defined?(@impersonating)
|
|
39
|
+
|
|
40
|
+
@impersonating = compute_impersonating?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start_impersonation_session(target_user, actor: true_user)
|
|
44
|
+
session[Studio.impersonation_actor_session_key] = actor.id
|
|
45
|
+
session[Studio.impersonation_target_session_key] = target_user.id
|
|
46
|
+
session[Studio.impersonation_started_at_session_key] = Time.current.iso8601
|
|
47
|
+
reset_impersonation_memoization
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_impersonation_session
|
|
51
|
+
session.delete(Studio.impersonation_actor_session_key)
|
|
52
|
+
session.delete(Studio.impersonation_target_session_key)
|
|
53
|
+
session.delete(Studio.impersonation_started_at_session_key)
|
|
54
|
+
reset_impersonation_memoization
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def impersonation_started_at
|
|
58
|
+
raw = session[Studio.impersonation_started_at_session_key]
|
|
59
|
+
return nil if raw.blank?
|
|
60
|
+
|
|
61
|
+
return raw if raw.is_a?(Time)
|
|
62
|
+
|
|
63
|
+
zone = Time.zone if Time.respond_to?(:zone)
|
|
64
|
+
zone ? zone.parse(raw.to_s) : Time.parse(raw.to_s)
|
|
65
|
+
rescue ArgumentError, TypeError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compute_impersonating?
|
|
70
|
+
return false if session[Studio.impersonation_target_session_key].blank?
|
|
71
|
+
return false unless true_user&.respond_to?(:admin?) && true_user.admin?
|
|
72
|
+
return false unless impersonation_started_at
|
|
73
|
+
return false if impersonation_started_at < Studio.impersonation_max_minutes.to_i.minutes.ago
|
|
74
|
+
|
|
75
|
+
target = impersonated_user
|
|
76
|
+
target.present? &&
|
|
77
|
+
(!target.respond_to?(:admin?) || !target.admin?) &&
|
|
78
|
+
target.id != true_user.id
|
|
79
|
+
rescue StandardError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reset_impersonation_memoization
|
|
84
|
+
remove_instance_variable(:@current_user) if defined?(@current_user)
|
|
85
|
+
remove_instance_variable(:@true_user) if defined?(@true_user)
|
|
86
|
+
remove_instance_variable(:@impersonated_user) if defined?(@impersonated_user)
|
|
87
|
+
remove_instance_variable(:@impersonating) if defined?(@impersonating)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -2,12 +2,29 @@ module StudioEmailDeliveryHelper
|
|
|
2
2
|
NON_DELIVERING_EMAIL_METHODS = %w[test file].freeze
|
|
3
3
|
|
|
4
4
|
def email_delivery_banner_status
|
|
5
|
+
details = email_delivery_banner_details
|
|
6
|
+
|
|
7
|
+
"EMAIL SEND #{details.fetch(:sends_email)} · #{details.fetch(:transport)}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def email_delivery_banner_details
|
|
5
11
|
delivery_method = studio_email_delivery_method
|
|
6
12
|
capture_enabled = Studio.local_email_capture?
|
|
7
13
|
sends_email = studio_email_perform_deliveries? && !capture_enabled &&
|
|
8
14
|
!NON_DELIVERING_EMAIL_METHODS.include?(delivery_method)
|
|
15
|
+
transport = email_delivery_transport_label(delivery_method, capture_enabled)
|
|
16
|
+
connector = email_delivery_connector(delivery_method, transport)
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
{
|
|
19
|
+
connector: connector,
|
|
20
|
+
connector_label: email_delivery_connector_label(connector),
|
|
21
|
+
email_state: sends_email ? "Sending" : "Captured",
|
|
22
|
+
provider_icon: email_delivery_provider_icon(connector),
|
|
23
|
+
sends_email: sends_email,
|
|
24
|
+
status_icon: sends_email ? "✅" : "❌",
|
|
25
|
+
tooltip: "Connector: #{email_delivery_connector_label(connector)} · Emails: #{sends_email ? "Sending" : "Captured"}",
|
|
26
|
+
transport: transport
|
|
27
|
+
}
|
|
11
28
|
end
|
|
12
29
|
|
|
13
30
|
def email_delivery_transport_label(delivery_method = studio_email_delivery_method,
|
|
@@ -21,6 +38,29 @@ module StudioEmailDeliveryHelper
|
|
|
21
38
|
|
|
22
39
|
private
|
|
23
40
|
|
|
41
|
+
def email_delivery_connector(delivery_method, transport)
|
|
42
|
+
return "ses" if Studio.ses_transport_ready?
|
|
43
|
+
return "resend" if delivery_method == "resend"
|
|
44
|
+
return transport if %w[ses resend].include?(transport)
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def email_delivery_connector_label(connector)
|
|
50
|
+
case connector
|
|
51
|
+
when "ses" then "SES"
|
|
52
|
+
when "resend" then "Resend"
|
|
53
|
+
else "Unknown"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def email_delivery_provider_icon(connector)
|
|
58
|
+
case connector
|
|
59
|
+
when "ses" then "ses-favicon.png"
|
|
60
|
+
when "resend" then "resend-favicon.png"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
24
64
|
def studio_email_delivery_method
|
|
25
65
|
return "unknown" unless defined?(ActionMailer)
|
|
26
66
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<%# locals: (records:) %>
|
|
2
|
+
<div class="overflow-x-auto">
|
|
3
|
+
<table id="models-arenas-table" class="w-full min-w-[940px] text-sm">
|
|
4
|
+
<thead class="bg-surface-alt text-muted text-xs uppercase tracking-wide">
|
|
5
|
+
<tr>
|
|
6
|
+
<th class="px-4 py-3 text-left font-semibold">Arena</th>
|
|
7
|
+
<th class="px-4 py-3 text-left font-semibold">Address</th>
|
|
8
|
+
<th class="px-4 py-3 text-left font-semibold">Location</th>
|
|
9
|
+
<th class="px-4 py-3 text-left font-semibold">Home Teams</th>
|
|
10
|
+
<th class="px-4 py-3 text-left font-semibold">Timezone</th>
|
|
11
|
+
</tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody class="divide-y divide-subtle">
|
|
14
|
+
<% records.each do |arena| %>
|
|
15
|
+
<tr class="hover:bg-surface-alt/60 transition">
|
|
16
|
+
<td class="px-4 py-3">
|
|
17
|
+
<div class="font-semibold text-heading truncate"><%= arena.name %></div>
|
|
18
|
+
<div class="text-xs text-muted font-mono truncate"><%= arena.slug %></div>
|
|
19
|
+
</td>
|
|
20
|
+
<td class="px-4 py-3 text-muted"><%= arena.address.presence || "-" %></td>
|
|
21
|
+
<td class="px-4 py-3 text-muted">
|
|
22
|
+
<%= [arena.location, arena.country].compact_blank.join(" / ").presence || "-" %>
|
|
23
|
+
</td>
|
|
24
|
+
<td class="px-4 py-3 text-muted">
|
|
25
|
+
<% if arena.home_teams.any? %>
|
|
26
|
+
<%= arena.home_teams.map(&:short_name).join(", ") %>
|
|
27
|
+
<% else %>
|
|
28
|
+
<span class="inline-flex items-center px-2 py-1 rounded text-[11px] font-bold uppercase bg-surface-alt text-secondary">
|
|
29
|
+
schedule-only
|
|
30
|
+
</span>
|
|
31
|
+
<% end %>
|
|
32
|
+
</td>
|
|
33
|
+
<td class="px-4 py-3 text-muted font-mono text-xs"><%= arena.timezone.presence || "-" %></td>
|
|
34
|
+
</tr>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if records.empty? %>
|
|
37
|
+
<tr>
|
|
38
|
+
<td colspan="5" class="px-4 py-10 text-center text-muted">No arenas found.</td>
|
|
39
|
+
</tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<%# locals: (records:) %>
|
|
2
|
+
<div class="overflow-x-auto">
|
|
3
|
+
<table id="models-teams-table" class="w-full min-w-[1040px] text-sm">
|
|
4
|
+
<thead class="bg-surface-alt text-muted text-xs uppercase tracking-wide">
|
|
5
|
+
<tr>
|
|
6
|
+
<th class="px-4 py-3 text-left font-semibold">
|
|
7
|
+
<%= link_to team_sort_url("team"), class: "inline-flex items-center gap-1 hover:text-heading" do %>
|
|
8
|
+
<span>Team</span>
|
|
9
|
+
<% if team_sort_indicator("team").present? %>
|
|
10
|
+
<span class="text-[10px] lowercase"><%= team_sort_indicator("team") %></span>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</th>
|
|
14
|
+
<th class="w-16 px-2 py-3 text-center font-semibold">
|
|
15
|
+
<%= link_to team_sort_url("sport"), class: "inline-flex items-center justify-center gap-1 hover:text-heading" do %>
|
|
16
|
+
<span>Sport</span>
|
|
17
|
+
<% if team_sort_indicator("sport").present? %>
|
|
18
|
+
<span class="text-[10px] lowercase"><%= team_sort_indicator("sport") %></span>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</th>
|
|
22
|
+
<th class="px-4 py-3 text-left font-semibold">
|
|
23
|
+
<%= link_to team_sort_url("league"), class: "inline-flex items-center gap-1 hover:text-heading" do %>
|
|
24
|
+
<span>League</span>
|
|
25
|
+
<% if team_sort_indicator("league").present? %>
|
|
26
|
+
<span class="text-[10px] lowercase"><%= team_sort_indicator("league") %></span>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</th>
|
|
30
|
+
<th class="px-4 py-3 text-left font-semibold">Conference / Division</th>
|
|
31
|
+
<th class="px-4 py-3 text-left font-semibold">Location</th>
|
|
32
|
+
<th class="px-4 py-3 text-left font-semibold">Home Arena</th>
|
|
33
|
+
<th class="px-4 py-3 text-left font-semibold">Logo</th>
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody class="divide-y divide-subtle">
|
|
37
|
+
<% records.each do |team| %>
|
|
38
|
+
<tr class="hover:bg-surface-alt/60 transition">
|
|
39
|
+
<td class="px-4 py-3">
|
|
40
|
+
<div x-data="{ open: false }">
|
|
41
|
+
<button type="button"
|
|
42
|
+
data-team-json-trigger="<%= team.slug %>"
|
|
43
|
+
class="w-full flex items-center gap-3 text-left rounded hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary/40"
|
|
44
|
+
aria-label="Open JSON for <%= team.name %>"
|
|
45
|
+
@click="open = true">
|
|
46
|
+
<span class="text-xl flex-shrink-0"><%= team.emoji.presence || "*" %></span>
|
|
47
|
+
<span class="min-w-0">
|
|
48
|
+
<span class="block font-semibold text-heading truncate"><%= team.name %></span>
|
|
49
|
+
<span class="block text-xs text-muted font-mono truncate"><%= team.short_name %> / <%= team.slug %></span>
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<template x-teleport="body">
|
|
54
|
+
<div x-show="open"
|
|
55
|
+
x-cloak
|
|
56
|
+
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/70 p-4"
|
|
57
|
+
role="dialog"
|
|
58
|
+
aria-modal="true"
|
|
59
|
+
aria-label="<%= team.name %> JSON"
|
|
60
|
+
@keydown.escape.window="open = false"
|
|
61
|
+
@click.self="open = false">
|
|
62
|
+
<div class="w-full max-w-3xl rounded-lg border border-strong bg-surface shadow-2xl">
|
|
63
|
+
<div class="flex items-center justify-between gap-4 border-b border-subtle px-4 py-3">
|
|
64
|
+
<div class="min-w-0">
|
|
65
|
+
<h3 class="text-heading font-semibold truncate"><%= team.name %></h3>
|
|
66
|
+
<p class="text-xs text-muted font-mono truncate"><%= team.slug %></p>
|
|
67
|
+
</div>
|
|
68
|
+
<button type="button" class="btn btn-outline btn-sm" @click="open = false">Close</button>
|
|
69
|
+
</div>
|
|
70
|
+
<pre class="max-h-[75vh] overflow-auto bg-inset p-4 text-xs text-secondary"><%= team_record_json(team) %></pre>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
</div>
|
|
75
|
+
</td>
|
|
76
|
+
<td class="w-16 px-2 py-3 text-center">
|
|
77
|
+
<span class="inline-flex w-8 items-center justify-center text-lg leading-none"
|
|
78
|
+
title="<%= team.sport.presence || "unknown sport" %>">
|
|
79
|
+
<%= team_sport_emoji(team) %>
|
|
80
|
+
</span>
|
|
81
|
+
</td>
|
|
82
|
+
<td class="px-4 py-3">
|
|
83
|
+
<span class="inline-flex items-center px-2 py-1 rounded text-[11px] font-bold uppercase bg-surface-alt text-secondary">
|
|
84
|
+
<%= team.league.presence || "-" %>
|
|
85
|
+
</span>
|
|
86
|
+
</td>
|
|
87
|
+
<td class="px-4 py-3 text-muted">
|
|
88
|
+
<%= [team.conference, team.division].compact_blank.join(" / ").presence || "-" %>
|
|
89
|
+
</td>
|
|
90
|
+
<td class="px-4 py-3 text-muted"><%= team.location.presence || "-" %></td>
|
|
91
|
+
<td class="px-4 py-3 text-muted"><%= team.home_arena&.name || "-" %></td>
|
|
92
|
+
<td class="px-4 py-3 text-muted">
|
|
93
|
+
<% logo_path = team.respond_to?(:logo_path) ? team.logo_path : nil %>
|
|
94
|
+
<% logo_url = team.respond_to?(:logo_url) ? team.logo_url : nil %>
|
|
95
|
+
<% logo_source = team.respond_to?(:logo_source) ? team.logo_source : nil %>
|
|
96
|
+
<% if logo_path.present? || logo_url.present? %>
|
|
97
|
+
<div class="max-w-[180px] truncate font-mono text-xs"><%= logo_path.presence || logo_url %></div>
|
|
98
|
+
<div class="text-[11px] text-muted"><%= logo_source.presence || "source pending" %></div>
|
|
99
|
+
<% else %>
|
|
100
|
+
<span class="text-xs text-muted">not set</span>
|
|
101
|
+
<% end %>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% if records.empty? %>
|
|
106
|
+
<tr>
|
|
107
|
+
<td colspan="7" class="px-4 py-10 text-center text-muted">No teams found.</td>
|
|
108
|
+
</tr>
|
|
109
|
+
<% end %>
|
|
110
|
+
</tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<% content_for(:title, "Models - Admin") %>
|
|
2
|
+
|
|
3
|
+
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
|
|
4
|
+
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
5
|
+
<div>
|
|
6
|
+
<p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin QA</p>
|
|
7
|
+
<h1 class="text-heading text-2xl font-bold mt-1">Models</h1>
|
|
8
|
+
<p class="text-muted text-sm mt-1">Ten-row snapshots for records that need quick inspection.</p>
|
|
9
|
+
</div>
|
|
10
|
+
<%= link_to "Back to dashboard", admin_dashboard_path, class: "btn btn-outline" %>
|
|
11
|
+
</header>
|
|
12
|
+
|
|
13
|
+
<div class="space-y-5">
|
|
14
|
+
<% @sections.each do |section| %>
|
|
15
|
+
<section id="model-<%= section.fetch(:key) %>" class="card overflow-hidden">
|
|
16
|
+
<div class="px-4 py-4 border-b border-subtle flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
17
|
+
<div>
|
|
18
|
+
<h2 class="text-heading text-lg font-semibold"><%= section.fetch(:label) %></h2>
|
|
19
|
+
<p class="text-xs text-muted"><%= section.fetch(:description) %></p>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="flex items-center gap-3">
|
|
22
|
+
<span class="text-xs text-muted">Showing <%= section.fetch(:records).size %> of <%= section.fetch(:count) %></span>
|
|
23
|
+
<%= link_to "View all", admin_model_path(section.fetch(:key)), class: "text-xs text-primary hover:underline" %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<%= render admin_models_table_partial(section.fetch(:key)), records: section.fetch(:records) %>
|
|
28
|
+
</section>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<% content_for(:title, "#{@config.fetch(:label)} - Models - Admin") %>
|
|
2
|
+
|
|
3
|
+
<div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
|
|
4
|
+
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
5
|
+
<div>
|
|
6
|
+
<p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin Models</p>
|
|
7
|
+
<h1 class="text-heading text-2xl font-bold mt-1"><%= @config.fetch(:label) %></h1>
|
|
8
|
+
<p class="text-muted text-sm mt-1">
|
|
9
|
+
<%= @config.fetch(:description) %>.
|
|
10
|
+
Page <%= @page %> of <%= @total_pages %>, <%= @total_count %> total.
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
<%= link_to "Back to models", admin_models_path, class: "btn btn-outline" %>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<section class="card overflow-hidden">
|
|
17
|
+
<%= render admin_models_table_partial(@key), records: @records %>
|
|
18
|
+
</section>
|
|
19
|
+
|
|
20
|
+
<nav class="flex items-center justify-between gap-3" aria-label="<%= @config.fetch(:label) %> pagination">
|
|
21
|
+
<% if @page > 1 %>
|
|
22
|
+
<%= link_to "Previous", admin_model_path(@key, page: @page - 1), class: "btn btn-outline" %>
|
|
23
|
+
<% else %>
|
|
24
|
+
<span class="btn btn-outline opacity-50 pointer-events-none">Previous</span>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<span class="text-xs text-muted">
|
|
28
|
+
Showing <%= @records.size %> records on page <%= @page %>
|
|
29
|
+
</span>
|
|
30
|
+
|
|
31
|
+
<% if @page < @total_pages %>
|
|
32
|
+
<%= link_to "Next", admin_model_path(@key, page: @page + 1), class: "btn btn-outline" %>
|
|
33
|
+
<% else %>
|
|
34
|
+
<span class="btn btn-outline opacity-50 pointer-events-none">Next</span>
|
|
35
|
+
<% end %>
|
|
36
|
+
</nav>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%
|
|
2
|
+
tone = local_assigns.fetch(:tone, :environment).to_sym
|
|
3
|
+
density = local_assigns.fetch(:density, :normal).to_sym
|
|
4
|
+
message = local_assigns.fetch(:message)
|
|
5
|
+
actions = local_assigns[:actions]
|
|
6
|
+
|
|
7
|
+
tones = {
|
|
8
|
+
environment: {
|
|
9
|
+
background: "linear-gradient(90deg, #9d174d 0%, #f72585 100%)",
|
|
10
|
+
color: "#ffffff",
|
|
11
|
+
shadow: "0 2px 8px rgba(0,0,0,0.25)"
|
|
12
|
+
},
|
|
13
|
+
impersonation: {
|
|
14
|
+
background: "linear-gradient(90deg, #b91c1c 0%, #d97706 100%)",
|
|
15
|
+
color: "#ffffff",
|
|
16
|
+
shadow: "0 2px 8px rgba(0,0,0,0.25)"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
density_styles = {
|
|
21
|
+
compact: "font-size:12px; font-weight:700; padding:2px 12px;",
|
|
22
|
+
normal: "font-size:14px; font-weight:700; padding:8px 16px;"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
style = tones.fetch(tone)
|
|
26
|
+
%>
|
|
27
|
+
|
|
28
|
+
<div class="w-full" style="background:<%= style.fetch(:background) %>; color:<%= style.fetch(:color) %>; box-shadow:<%= style.fetch(:shadow) %>;">
|
|
29
|
+
<div class="max-w-7xl mx-auto flex items-center justify-between gap-3" style="<%= density_styles.fetch(density) %>">
|
|
30
|
+
<span class="min-w-0 truncate"><%= message %></span>
|
|
31
|
+
<% if actions.present? %>
|
|
32
|
+
<div class="shrink-0 flex items-center gap-2"><%= actions %></div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<%
|
|
2
|
+
label = local_assigns[:label]
|
|
3
|
+
content = local_assigns[:content] || label
|
|
4
|
+
href = local_assigns[:href]
|
|
5
|
+
as = local_assigns.fetch(:as, href.present? ? :link : :button).to_sym
|
|
6
|
+
method = local_assigns[:method]
|
|
7
|
+
tooltip = local_assigns[:tooltip]
|
|
8
|
+
aria_label = local_assigns[:aria_label] || tooltip || label.to_s
|
|
9
|
+
variant = local_assigns.fetch(:variant, :outline).to_sym
|
|
10
|
+
hover_text_color = local_assigns.fetch(:hover_text_color, "#be185d")
|
|
11
|
+
|
|
12
|
+
base_class = "group relative inline-flex items-center font-semibold rounded px-3 py-1 whitespace-nowrap gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/90"
|
|
13
|
+
classes = [base_class, local_assigns[:class]].compact.join(" ")
|
|
14
|
+
|
|
15
|
+
tooltip_show = tooltip.present? ? "var t=this.querySelector('[data-studio-banner-tooltip]'); if(t){t.style.opacity='1';}" : ""
|
|
16
|
+
tooltip_hide = tooltip.present? ? "var t=this.querySelector('[data-studio-banner-tooltip]'); if(t){t.style.opacity='0';}" : ""
|
|
17
|
+
|
|
18
|
+
if variant == :solid
|
|
19
|
+
default_background = local_assigns.fetch(:background, "#ffffff")
|
|
20
|
+
default_border = local_assigns.fetch(:border_color, "#ffffff")
|
|
21
|
+
default_color = local_assigns.fetch(:text_color, hover_text_color)
|
|
22
|
+
hover_background = local_assigns.fetch(:hover_background, "#fff7ed")
|
|
23
|
+
hover_border = local_assigns.fetch(:hover_border_color, hover_background)
|
|
24
|
+
hover_color = local_assigns.fetch(:hover_text_color, default_color)
|
|
25
|
+
else
|
|
26
|
+
default_background = "transparent"
|
|
27
|
+
default_border = "rgba(255,255,255,0.9)"
|
|
28
|
+
default_color = "#ffffff"
|
|
29
|
+
hover_background = "#ffffff"
|
|
30
|
+
hover_border = "#ffffff"
|
|
31
|
+
hover_color = hover_text_color
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
base_style = [
|
|
35
|
+
"appearance:none",
|
|
36
|
+
"background:#{default_background}",
|
|
37
|
+
"border:1px solid #{default_border}",
|
|
38
|
+
"color:#{default_color}",
|
|
39
|
+
"cursor:pointer",
|
|
40
|
+
"font:inherit",
|
|
41
|
+
"font-weight:700",
|
|
42
|
+
"text-decoration:none",
|
|
43
|
+
"position:relative",
|
|
44
|
+
"transition:background-color 150ms ease, color 150ms ease, border-color 150ms ease"
|
|
45
|
+
].join("; ")
|
|
46
|
+
|
|
47
|
+
show_styles = "this.style.background='#{hover_background}'; this.style.borderColor='#{hover_border}'; this.style.color='#{hover_color}';#{tooltip_show}"
|
|
48
|
+
hide_styles = "this.style.background='#{default_background}'; this.style.borderColor='#{default_border}'; this.style.color='#{default_color}';#{tooltip_hide}"
|
|
49
|
+
|
|
50
|
+
attrs = {
|
|
51
|
+
class: classes,
|
|
52
|
+
style: base_style,
|
|
53
|
+
title: tooltip,
|
|
54
|
+
"aria-label": aria_label.presence,
|
|
55
|
+
onmouseover: show_styles,
|
|
56
|
+
onmouseout: hide_styles,
|
|
57
|
+
onfocus: show_styles,
|
|
58
|
+
onblur: hide_styles
|
|
59
|
+
}.compact
|
|
60
|
+
|
|
61
|
+
attrs[:"@click"] = local_assigns[:alpine_click] if local_assigns[:alpine_click].present?
|
|
62
|
+
|
|
63
|
+
if tooltip.present?
|
|
64
|
+
tooltip_content = capture do
|
|
65
|
+
%>
|
|
66
|
+
<span data-studio-banner-tooltip
|
|
67
|
+
class="pointer-events-none absolute right-0 top-full z-[140] mt-2 w-max max-w-[260px] rounded bg-white px-3 py-2 text-xs font-semibold text-slate-900 opacity-0 shadow-lg ring-1 ring-black/10 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
|
|
68
|
+
style="position:absolute; right:0; top:100%; z-index:140; margin-top:8px; max-width:260px; width:max-content; border-radius:4px; background:#ffffff; color:#111827; padding:8px 12px; font-size:12px; font-weight:700; line-height:1.2; opacity:0; pointer-events:none; box-shadow:0 8px 18px rgba(0,0,0,0.18); transition:opacity 150ms ease;">
|
|
69
|
+
<%= tooltip %>
|
|
70
|
+
</span>
|
|
71
|
+
<%
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
%>
|
|
75
|
+
|
|
76
|
+
<% if as == :button %>
|
|
77
|
+
<%= button_tag attrs.merge(type: "button") do %>
|
|
78
|
+
<%= content %><%= tooltip_content if tooltip.present? %>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% elsif method.present? %>
|
|
81
|
+
<%= button_to href,
|
|
82
|
+
attrs.merge(method: method, form_class: local_assigns.fetch(:form_class, "shrink-0")) do %>
|
|
83
|
+
<%= content %><%= tooltip_content if tooltip.present? %>
|
|
84
|
+
<% end %>
|
|
85
|
+
<% else %>
|
|
86
|
+
<%= link_to href, attrs do %>
|
|
87
|
+
<%= content %><%= tooltip_content if tooltip.present? %>
|
|
88
|
+
<% end %>
|
|
89
|
+
<% end %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<% details = email_delivery_banner_details %>
|
|
2
|
+
|
|
3
|
+
<% content = capture do %>
|
|
4
|
+
<span>Email</span>
|
|
5
|
+
<span aria-hidden="true"><%= details.fetch(:status_icon) %></span>
|
|
6
|
+
<% if details[:provider_icon].present? %>
|
|
7
|
+
<%= image_tag details.fetch(:provider_icon),
|
|
8
|
+
alt: "",
|
|
9
|
+
"aria-hidden": true,
|
|
10
|
+
width: 20,
|
|
11
|
+
height: 20,
|
|
12
|
+
class: "rounded",
|
|
13
|
+
style: "width:20px; height:20px; object-fit:cover;" %>
|
|
14
|
+
<% else %>
|
|
15
|
+
<span aria-hidden="true">❓</span>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<%= render "studio/banners/button",
|
|
20
|
+
href: "/_studio/local_emails",
|
|
21
|
+
label: "Email",
|
|
22
|
+
content: content,
|
|
23
|
+
tooltip: details.fetch(:tooltip) %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%
|
|
2
|
+
environment_label = local_assigns.fetch(:environment_label, "#{Rails.env.capitalize} Environment")
|
|
3
|
+
devnet = local_assigns.fetch(:devnet, false)
|
|
4
|
+
%>
|
|
5
|
+
|
|
6
|
+
<% actions = capture do %>
|
|
7
|
+
<% if devnet %>
|
|
8
|
+
<span class="rounded" style="font-size:10px; font-weight:700; padding:1px 6px; background:rgba(0,0,0,0.2);">DEVNET</span>
|
|
9
|
+
<% end %>
|
|
10
|
+
<%= render "studio/banners/dev_mode_button" %>
|
|
11
|
+
<%= render "studio/banners/email_status_button" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<%= render "studio/banners/app_banner",
|
|
15
|
+
tone: :environment,
|
|
16
|
+
density: :normal,
|
|
17
|
+
message: environment_label,
|
|
18
|
+
actions: actions %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%
|
|
2
|
+
impersonated_user = local_assigns.fetch(:impersonated_user)
|
|
3
|
+
admin_user = local_assigns.fetch(:admin_user)
|
|
4
|
+
stop_path = local_assigns.fetch(:stop_path)
|
|
5
|
+
%>
|
|
6
|
+
|
|
7
|
+
<% message = capture do %>
|
|
8
|
+
Logged in as <strong><%= impersonated_user.display_name %></strong><span class="hidden sm:inline" style="opacity:0.85;"> - admin impersonation</span>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% actions = capture do %>
|
|
11
|
+
<%= render "studio/banners/button",
|
|
12
|
+
href: stop_path,
|
|
13
|
+
method: :delete,
|
|
14
|
+
variant: :solid,
|
|
15
|
+
label: "Return to #{admin_user.display_name}",
|
|
16
|
+
text_color: "#b91c1c",
|
|
17
|
+
hover_text_color: "#b91c1c" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%= render "studio/banners/app_banner",
|
|
21
|
+
tone: :impersonation,
|
|
22
|
+
density: :normal,
|
|
23
|
+
message: message,
|
|
24
|
+
actions: actions %>
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -39,6 +39,14 @@ module Studio
|
|
|
39
39
|
# NAMES at boot, keeping its own routes intact. New consumers leave it true.
|
|
40
40
|
mattr_accessor :draw_auth_routes, default: true
|
|
41
41
|
|
|
42
|
+
# Optional admin Act As / impersonation session conventions. Consumers that
|
|
43
|
+
# include Studio::Impersonation get current_user layered over true_user with
|
|
44
|
+
# these session keys, but still own authorization, audit logging, and routes.
|
|
45
|
+
mattr_accessor :impersonation_target_session_key, default: :impersonated_user_id
|
|
46
|
+
mattr_accessor :impersonation_actor_session_key, default: :true_admin_id
|
|
47
|
+
mattr_accessor :impersonation_started_at_session_key, default: :impersonation_started_at
|
|
48
|
+
mattr_accessor :impersonation_max_minutes, default: 30
|
|
49
|
+
|
|
42
50
|
# Default From: for engine-sent mail (magic links). Apps set this to their
|
|
43
51
|
# verified sending address in config/initializers/studio.rb.
|
|
44
52
|
mattr_accessor :mailer_from, default: nil
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -140,8 +139,12 @@ files:
|
|
|
140
139
|
- Gemfile
|
|
141
140
|
- LICENSE
|
|
142
141
|
- README.md
|
|
142
|
+
- app/assets/images/resend-favicon.png
|
|
143
|
+
- app/assets/images/ses-favicon.png
|
|
143
144
|
- app/controllers/concerns/solana/session_auth.rb
|
|
145
|
+
- app/controllers/concerns/studio/admin_models.rb
|
|
144
146
|
- app/controllers/concerns/studio/error_handling.rb
|
|
147
|
+
- app/controllers/concerns/studio/impersonation.rb
|
|
145
148
|
- app/controllers/error_logs_controller.rb
|
|
146
149
|
- app/controllers/magic_links_controller.rb
|
|
147
150
|
- app/controllers/navbar_controller.rb
|
|
@@ -193,6 +196,16 @@ files:
|
|
|
193
196
|
- app/views/sessions/_sso_continue.html.erb
|
|
194
197
|
- app/views/sessions/new.html.erb
|
|
195
198
|
- app/views/studio/_cropper_assets.html.erb
|
|
199
|
+
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
200
|
+
- app/views/studio/admin_models/_teams_table.html.erb
|
|
201
|
+
- app/views/studio/admin_models/index.html.erb
|
|
202
|
+
- app/views/studio/admin_models/show.html.erb
|
|
203
|
+
- app/views/studio/banners/_app_banner.html.erb
|
|
204
|
+
- app/views/studio/banners/_button.html.erb
|
|
205
|
+
- app/views/studio/banners/_dev_mode_button.html.erb
|
|
206
|
+
- app/views/studio/banners/_email_status_button.html.erb
|
|
207
|
+
- app/views/studio/banners/_environment.html.erb
|
|
208
|
+
- app/views/studio/banners/_impersonation.html.erb
|
|
196
209
|
- app/views/studio/local_emails/index.html.erb
|
|
197
210
|
- app/views/studio/modals/_crop_photo.html.erb
|
|
198
211
|
- app/views/studio/modals/_host.html.erb
|
|
@@ -230,7 +243,6 @@ metadata:
|
|
|
230
243
|
source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
|
|
231
244
|
bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
|
|
232
245
|
changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
|
|
233
|
-
post_install_message:
|
|
234
246
|
rdoc_options: []
|
|
235
247
|
require_paths:
|
|
236
248
|
- lib
|
|
@@ -245,8 +257,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
245
257
|
- !ruby/object:Gem::Version
|
|
246
258
|
version: '0'
|
|
247
259
|
requirements: []
|
|
248
|
-
rubygems_version:
|
|
249
|
-
signing_key:
|
|
260
|
+
rubygems_version: 4.0.9
|
|
250
261
|
specification_version: 4
|
|
251
262
|
summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
|
|
252
263
|
image caching
|