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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cba132c4901014fde9dfb1404661065d526eebf7c3db40f29198413bf6aee994
4
- data.tar.gz: 676040a89d09f62b86f3bf25ffa8a07418d9976900377e8dbdab0c8088194687
3
+ metadata.gz: '0817243bffbcd834fd5a3d8f593a420b12d8fba691c48337b68e6661ebbe5d7c'
4
+ data.tar.gz: 5328f4a61db1c6c8965a0291bf0573a1e71b9e83b29e8eafe0a974b5208a0487
5
5
  SHA512:
6
- metadata.gz: c33acfe1016923235e9de220b719922b9885b4da918205004b18337722d07c17f2077a67d5fe7074c48202cd01cb193fc49fe0669bcf9ea02dd0325362a6a1a2
7
- data.tar.gz: cbe15ef16f573cba78e7bf494da84b68e144f0cd0790695f8393f5d37a022ec37e6b1562a6be5babeab53eed61f35c8fd7f4fa544b60e540d2ab337f1a0dc3cd
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.9**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
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
@@ -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
- "EMAIL SEND #{sends_email} · #{email_delivery_transport_label(delivery_method, capture_enabled)}"
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,4 @@
1
+ <%= render "studio/banners/button",
2
+ as: :button,
3
+ label: "DEV MODE",
4
+ alpine_click: "$store.devMode = !$store.devMode; localStorage.setItem('devMode', $store.devMode)" %>
@@ -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 %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.5.9"
2
+ VERSION = "0.5.10"
3
3
  end
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.9
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: 2026-06-15 00:00:00.000000000 Z
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: 3.5.11
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