rollout-ui 0.9.3 → 0.10.1

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: 4af968fe0271d80faec7b339779f81a5b8eb9bd06d23b984c7fb5436384b0593
4
- data.tar.gz: 4d4ef32df14c4282b72ea2c5f6a3fee00266dd137ea612b211bcab1e9bc0786f
3
+ metadata.gz: 8fe93ad84dab349410b8d45fd548837b3c7057db163916ae51af5415f0b6a9bd
4
+ data.tar.gz: 6b451b50cc1e907a63e94976c103800f26fa6366705ed34e4a31d2faad585507
5
5
  SHA512:
6
- metadata.gz: 6af36b891a09fcf1f5f22abe858c10c266db3c9c48444d58e3a72f38daa1dfa3e444b7e443857d77261d041540c0533501e9548f1ad6e7582902773b0c399f85
7
- data.tar.gz: cbeb563460654cb4405a3f2eaaf515426d92311f0ae3eb32dd8eaeea1455262b86e80acb2379c41556459ab579f8cb779cb8923522bdb0dc42c4ff19d67fb369
6
+ metadata.gz: '09ad9b11897744854625b4654e7d2a5c543492ac945ad6b91e5359b1c6aad0bab6ba0927781588f4c8b3c16e221597f625cf9acc4ca1e6c208c260d20a6e6ee8'
7
+ data.tar.gz: 837fa191cde919c0a88f881cc31fe300311026acf7559410f3f23bdc9ffd27c5d6f9644955337d64019c96e2642343837569d8da31648706bdf7a80d411b9bbb
@@ -7,7 +7,8 @@ on:
7
7
 
8
8
  jobs:
9
9
  release:
10
- runs-on: ubuntu-latest
10
+ # setup-ruby has no build of the .ruby-version Ruby (3.4.7) for ubuntu-26.04.
11
+ runs-on: ubuntu-24.04
11
12
  permissions:
12
13
  contents: write
13
14
  id-token: write
@@ -37,7 +38,7 @@ jobs:
37
38
 
38
39
  github-release:
39
40
  needs: release
40
- runs-on: ubuntu-latest
41
+ runs-on: ubuntu-26.04
41
42
  permissions:
42
43
  contents: write
43
44
  steps:
@@ -5,12 +5,11 @@ on:
5
5
  branches:
6
6
  - master
7
7
  pull_request:
8
- branches:
9
- - master
10
8
 
11
9
  jobs:
12
10
  test:
13
- runs-on: ubuntu-latest
11
+ # setup-ruby has no build of the .ruby-version Ruby (3.4.7) for ubuntu-26.04.
12
+ runs-on: ubuntu-24.04
14
13
  services:
15
14
  redis:
16
15
  image: redis:7-alpine
data/README.md CHANGED
@@ -1,14 +1,12 @@
1
1
  # Rollout::UI
2
2
 
3
- Minimalist UI for the [rollout](https://github.com/fetlife/rollout) gem that
4
- you can just mount as a Rack app and it will just work.
3
+ A Rack-mountable UI for the [rollout](https://github.com/fetlife/rollout) gem.
5
4
 
6
5
  ![Index Page](./screenshot_index.png)
7
6
  <!-- ![Feature Page](./screenshot_show.png) -->
8
7
 
9
8
  ## Usage with Rails
10
9
 
11
-
12
10
  Add it to your application's Gemfile:
13
11
 
14
12
  ```ruby
@@ -17,20 +15,20 @@ gem "rollout-redis-adapter", "~> 0.1"
17
15
  gem "rollout-ui"
18
16
  ```
19
17
 
20
- `rollout-ui` 0.9+ requires Rollout 3.1 and does not support Rollout 2.x. The UI is backend-neutral: applications using Redis should add `rollout-redis-adapter` and pass a configured Rollout instance into the UI.
18
+ `rollout-ui` 0.9+ requires Rollout 3.1 or later within 3.x. It works with any
19
+ Rollout adapter; Redis applications also need `rollout-redis-adapter`.
21
20
 
22
- Mount it
21
+ Mount the Rack app:
23
22
 
24
23
  ```ruby
25
24
  Rails.application.routes.draw do
26
- mount Rollout::UI::Web.new => '/admin/rollout'
25
+ mount Rollout::UI::Web.new => "/admin/rollout"
27
26
 
28
27
  # ...
29
28
  end
30
29
  ```
31
30
 
32
- And to configure it with your `Rollout` instance, you can put your configuration
33
- in `routes.rb` or in a standalone initializer.
31
+ Configure your Rollout instance in `routes.rb` or an initializer:
34
32
 
35
33
  ```ruby
36
34
  Rollout::UI.configure do
@@ -40,12 +38,10 @@ end
40
38
 
41
39
  ## Authentication
42
40
 
43
- If you are using Rails, you can put `constraints` on your mount.
44
-
45
- So in case of usafe with Devise, your constraints might look like:
41
+ Protect the Rails mount with a constraint. For example, with Devise:
46
42
 
47
43
  ```ruby
48
- module Constraint::Admin
44
+ class AdminConstraint
49
45
  def self.matches?(request)
50
46
  id = request.session["warden.user.user.key"].try(:[], 0).try(:[], 0)
51
47
  return false if id.blank?
@@ -56,32 +52,119 @@ module Constraint::Admin
56
52
  end
57
53
 
58
54
  Rails.application.routes.draw do
59
- mount Rollout::UI::Web.new => '/admin/rollout', constraints: Constraints::Admin
55
+ mount Rollout::UI::Web.new => "/admin/rollout", constraints: AdminConstraint
60
56
 
61
57
  # ...
62
58
  end
63
59
  ```
64
60
 
65
- ## Host Header Validation
61
+ ## Nickname user selector
66
62
 
67
- This gem performs no Host header validation of its own. When mounted inside a Rails app (as shown above), requests already pass through Rails' own `config.hosts` checks before reaching this mount point.
63
+ Configure **both** callbacks below to replace the user ID field with a searchable
64
+ nickname picker alongside the enrolled-user list. Your application supplies the
65
+ queries; the UI displays nicknames and Rollout stores user IDs. This example
66
+ uses Rails and PostgreSQL:
68
67
 
69
- If you run this as a standalone app (e.g. via `rackup`), you're responsible for host header validation in the same way you're responsible for authentication, for example with
70
- [`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api) in front of it, or a reverse proxy that only forwards trusted hosts.
68
+ ```ruby
69
+ Rollout::UI.configure do
70
+ instance { $rollout }
71
71
 
72
- ## API Endpoints
72
+ user_search do |query, limit|
73
+ prefix = User.sanitize_sql_like(query.downcase) + "%"
74
+ User.where("lower(nickname) LIKE ?", prefix)
75
+ .limit(limit)
76
+ .pluck(:id, :nickname)
77
+ .map { |id, nickname| { id: id.to_s, nickname: nickname } }
78
+ end
73
79
 
74
- The index and show routes can also respond with JSON data instead of HTML when the request's `Accept` header is
75
- `application/json`
80
+ user_lookup do |ids|
81
+ User.where(id: ids)
82
+ .pluck(:id, :nickname)
83
+ .map { |id, nickname| { id: id.to_s, nickname: nickname } }
84
+ end
85
+ end
86
+ ```
76
87
 
77
- The index route also accepts query parameters to filter by user or group:
78
- `/admin/rollout?user=someone`
79
- `/admin/rollout?group=developers`
88
+ Both callbacks return arrays of hashes with `id` and `nickname` keys (symbols or
89
+ strings). `user_search` receives a trimmed nickname query and a result limit.
90
+ `user_lookup` receives a batch of selected or historical user IDs as strings;
91
+ omit missing accounts. Apply your account visibility rules in both callbacks.
92
+
93
+ Search defaults to **3 characters** and **20 results**, with a 300 ms debounce.
94
+ Override these settings inside the same configuration block:
95
+
96
+ ```ruby
97
+ user_search_min_length { 3 }
98
+ user_search_limit { 20 }
99
+ ```
100
+
101
+ For large directories, use **indexed nickname-only prefix matching** and apply
102
+ the limit in the database. Escape wildcards as above; avoid loading all users or
103
+ running unindexed `%contains%` queries. A matching index for this example is:
104
+
105
+ ```sql
106
+ CREATE INDEX CONCURRENTLY users_nickname_prefix_idx
107
+ ON users (lower(nickname) text_pattern_ops);
108
+ ```
109
+
110
+ Check the query plan against your schema and collation. The selector supports
111
+ keyboard navigation and preserves pending changes if a lookup fails. The
112
+ enrolled count includes unresolved accounts, shown as `Unknown user` with their
113
+ ID in a separate column, but excludes users enabled through groups or percentage.
114
+
115
+ ### Paginated enrollment editing
116
+
117
+ Every feature shows a paginated enrollment list with **25 users per page**.
118
+ Rows are ordered by user ID. Nicknames are
119
+ looked up only for the visible page, independently of the bounded history lookup.
120
+ There is no longer a 150-user enrollment editing cutoff.
121
+
122
+ Remove/Undo and nickname additions are staged until **Update**. With JavaScript,
123
+ switching enrollment pages leaves pending changes and unsaved description,
124
+ groups, and percentage fields intact. A pending-changes callout below the table
125
+ opens a **Review** popover listing additions and removals across all pages. Search marks
126
+ already-enrolled users even when they are on another page. The heading counts
127
+ stored enrollments; pending changes are reported separately until saved.
128
+
129
+ The form submits additions and removals, not a replacement list from the visible
130
+ page. Delta requests use `user_changes=1` and require a full-state
131
+ `feature_version`; malformed markers and missing versions are rejected. After
132
+ version validation, these deltas are applied to the full membership
133
+ inside `with_feature`. Duplicate additions are idempotent; removals take
134
+ precedence if an ID appears in both sets. Legacy full-list `users` submissions
135
+ remain supported.
136
+
137
+ Without JavaScript or nickname callbacks, add users using comma-separated IDs
138
+ and check Remove for visible rows. **Update before following page links** to
139
+ avoid discarding unsaved form changes. Enrollment pagination remains available
140
+ when nickname lookup fails; unknown IDs are never silently dropped.
141
+
142
+ ## Concurrent edits
143
+
144
+ Feature forms include a `feature_version` fingerprint of the editable state:
145
+ percentage, groups, enrolled IDs, and description. Ordering of IDs and groups,
146
+ timestamps, and unrelated metadata do not change the fingerprint. Distinct edits
147
+ in the same second and changes made outside the UI are detected.
148
+
149
+ Updates validate the submitted fingerprint inside `with_feature`, before changing
150
+ state. A stale submission is rejected without saving state or history, and the
151
+ UI redirects to the latest feature so the administrator can review and retry.
152
+ Older non-delta forms without `feature_version` retain the `last_updated_at`
153
+ timestamp check; that legacy check cannot distinguish same-second edits.
154
+
155
+ Atomic conflict detection depends on the adapter's mutation guarantees. The
156
+ ActiveRecord adapter locks existing feature rows during `with_feature`. The
157
+ Redis adapter does not provide the equivalent lock or compare-and-set guarantee.
158
+
159
+ Use uncached feature reads for the Rollout instance supplied to the admin UI.
160
+ Process-local adapter caches can otherwise display an older enrolled list and
161
+ form version after another worker saves. Normal feature evaluation can use a
162
+ separate cached instance with the same group definitions. Feature responses send
163
+ `Cache-Control: no-store`; this does not bypass an adapter's server-side cache.
80
164
 
81
165
  ## Logging
82
166
 
83
- To get the most out of **rollout-ui**, we recommend you to turn on logging
84
- on your rollout instance to see history of changes in the UI.
167
+ Enable logging on your Rollout instance to display change history:
85
168
 
86
169
  ```ruby
87
170
  require "redis"
@@ -95,14 +178,12 @@ $rollout = Rollout.new(
95
178
  )
96
179
  ```
97
180
 
98
- To also see who updated states of your rollouts, you can configure `actor` and
99
- `actor_url`. So if you are using Rails with Devise, your configuration might
100
- look like:
181
+ Configure `actor` and `actor_url` to attribute changes. For example, with Devise:
101
182
 
102
183
  ```ruby
103
184
  Rollout::UI.configure do
104
185
  instance { $rollout }
105
- actor { current_user&.username }
186
+ actor { current_user&.nickname }
106
187
  actor_url { |actor| "/#{actor}" }
107
188
  end
108
189
  ```
@@ -112,6 +193,46 @@ deletions appear in the overview history with the configured actor. This
112
193
  requires global logging (`global: true`); adapters without event-aware deletion
113
194
  retain the previous behavior and do not add a deletion event.
114
195
 
196
+ With `user_lookup` configured, feature and overview history display current
197
+ nicknames while audit events retain IDs. History resolves up to 150 additional
198
+ distinct IDs per page; missing accounts, failed lookups, and IDs beyond that limit
199
+ use ID-based labels.
200
+
201
+ ## API Endpoints
202
+
203
+ Send `Accept: application/json` to the index or feature route for JSON responses.
204
+ The index also accepts filters such as `/admin/rollout?user=123` and
205
+ `/admin/rollout?group=developers`.
206
+
207
+ When both nickname callbacks are configured, `GET /users/search?q=alice`
208
+ (relative to the mount) returns `{"users":[{"id":"123","nickname":"alice"}]}`.
209
+ It uses the mount's authentication, caps results server-side, and sends
210
+ `Cache-Control: no-store`. Queries below the configured minimum return an empty
211
+ list without calling `user_search`. Queries are limited to 100 characters;
212
+ configured minimum lengths and result limits are bounded to 1–100.
213
+
214
+ `GET /features/:name/users?page=2` returns the visible enrollment
215
+ page, total enrollment count, page metadata, full-state `feature_version`, and
216
+ `lookup_failed`. Pages contain at most 25 users and page numbers are clamped to
217
+ the available range. The response never contains the entire
218
+ membership list and sends `Cache-Control: no-store`.
219
+
220
+ `GET /features/:name/users/search?q=alice` uses the same nickname callbacks and
221
+ limits, adds an `enrolled` flag to each match based on the full membership, and
222
+ includes `feature_version`. The unscoped `/users/search` response is unchanged.
223
+ Paginated reads never replace the form's original version token; if the rollout
224
+ changes while editing, the administrator must review the newer state.
225
+
226
+ ## Host Header Validation
227
+
228
+ When mounted in Rails, requests pass through Rails' `config.hosts` checks. The gem
229
+ performs no additional Host header validation.
230
+
231
+ For standalone use (e.g. `rackup`), configure authentication and host validation
232
+ through middleware such as
233
+ [`Rack::Protection::HostAuthorization`](https://github.com/sinatra/sinatra/tree/main/rack-protection#host-authorization-api)
234
+ or a reverse proxy that only forwards trusted hosts.
235
+
115
236
  ## Contributing
116
237
 
117
238
  Bug reports and pull requests are welcome on GitHub at https://github.com/fetlife/rollout-ui.
@@ -141,7 +262,7 @@ bundle exec rerun rackup
141
262
 
142
263
  And visit [http://localhost:9292/](http://localhost:9292/).
143
264
 
144
- Alternatively, you can also configure which Redis with:
265
+ To use a different Redis connection:
145
266
 
146
267
  ```sh
147
268
  REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=10 bundle exec rerun rackup
@@ -9,10 +9,16 @@ module Rollout::UI
9
9
  actor
10
10
  actor_url
11
11
  timestamp_format
12
+ user_search
13
+ user_lookup
14
+ user_search_min_length
15
+ user_search_limit
12
16
  ].freeze
13
17
 
14
18
  DEFAULT_VALUES = {
15
- timestamp_format: '%Y-%m-%d %H:%M %Z'
19
+ timestamp_format: '%Y-%m-%d %H:%M %Z',
20
+ user_search_min_length: 3,
21
+ user_search_limit: 20
16
22
  }.freeze
17
23
 
18
24
  KEYS.each do |key|
@@ -1,3 +1,5 @@
1
+ require "digest/sha2"
2
+ require "json"
1
3
  require "rack"
2
4
  require "rollout"
3
5
 
@@ -5,10 +7,60 @@ require "rollout/ui/version"
5
7
 
6
8
  module Rollout::UI
7
9
  module Helpers
10
+ USER_PAGE_SIZE = 25
11
+ USER_SEARCH_MAX_LENGTH = 100
12
+
8
13
  def stylesheet_path(name)
9
14
  "#{request.script_name}/css/#{name}.css"
10
15
  end
11
16
 
17
+ def javascript_path(name)
18
+ "#{request.script_name}/js/#{name}.js"
19
+ end
20
+
21
+ def user_search_path
22
+ "#{request.script_name}/users/search"
23
+ end
24
+
25
+ def enrolled_users_path(feature_name)
26
+ "#{request.script_name}/features/#{feature_name}/users"
27
+ end
28
+
29
+ def feature_user_search_path(feature_name)
30
+ "#{request.script_name}/features/#{feature_name}/users/search"
31
+ end
32
+
33
+ def user_selector_enabled?
34
+ config.defined?(:user_search) && config.defined?(:user_lookup)
35
+ end
36
+
37
+ def user_search_min_length
38
+ config.get(:user_search_min_length).to_i.clamp(1, USER_SEARCH_MAX_LENGTH)
39
+ end
40
+
41
+ def user_search_limit
42
+ config.get(:user_search_limit).to_i.clamp(1, 100)
43
+ end
44
+
45
+ def user_label(id, include_id: true)
46
+ id = id.to_s
47
+ return @user_nicknames[id] if @user_nicknames&.key?(id)
48
+
49
+ label = @user_lookup_attempted&.key?(id) ? "Unknown user" : "User"
50
+ include_id ? "#{label} (##{id})" : label
51
+ end
52
+
53
+ def feature_version(feature)
54
+ state = [
55
+ feature.name.to_s,
56
+ feature.percentage.to_f,
57
+ feature.groups.map(&:to_s).uniq.sort,
58
+ feature.users.map(&:to_s).uniq.sort,
59
+ feature.data["description"].to_s,
60
+ ]
61
+ Digest::SHA256.hexdigest(state.to_json)
62
+ end
63
+
12
64
  def index_path
13
65
  "#{request.script_name}/"
14
66
  end
@@ -84,10 +136,15 @@ module Rollout::UI
84
136
  key.to_s.gsub('data.', '')
85
137
  end
86
138
 
87
- def format_change_value(value)
139
+ def format_change_value(value, key: nil)
88
140
  case value
89
141
  when Array
90
- "[#{value.join(', ')}]"
142
+ values = if key.to_s == 'users' && config.defined?(:user_lookup)
143
+ value.map { |id| user_label(id) }
144
+ else
145
+ value
146
+ end
147
+ "[#{values.join(', ')}]"
91
148
  when String, nil
92
149
  "'#{value}'"
93
150
  else
@@ -106,9 +163,16 @@ module Rollout::UI
106
163
  def event_summary(event)
107
164
  return "deleted this feature" if event.name.to_s == "delete"
108
165
 
109
- changes = event.data.fetch(:before).keys.reject { |key| key.to_s == 'data.updated_at' }.map do |key|
110
- before = format_change_value(event.data.fetch(:before).fetch(key))
111
- after = format_change_value(event.data.fetch(:after).fetch(key))
166
+ changes = event.data.fetch(:before).keys.filter_map do |key|
167
+ next if key.to_s == 'data.updated_at'
168
+
169
+ before_value = event.data.fetch(:before).fetch(key)
170
+ after_value = event.data.fetch(:after).fetch(key)
171
+ next if before_value == after_value
172
+ next if key.to_s == 'data.description' && before_value.to_s == after_value.to_s
173
+
174
+ before = format_change_value(before_value, key: key)
175
+ after = format_change_value(after_value, key: key)
112
176
  "#{format_change_key(key)} from #{before} to #{after}"
113
177
  end
114
178
  "changed #{changes.empty? ? 'nothing!' : changes.join(', ')}"
@@ -0,0 +1,191 @@
1
+ #enrolled-users .enrolled-user-search-field,
2
+ #enrolled-users #pending-user-slot {
3
+ position: relative;
4
+ }
5
+
6
+ #enrolled-users .text-gray-500,
7
+ #enrolled-users .text-gray-600,
8
+ #enrolled-users .text-gray-400 {
9
+ color: #4a5568;
10
+ }
11
+
12
+ .dark #enrolled-users .text-gray-500,
13
+ .dark #enrolled-users .text-gray-600,
14
+ .dark #enrolled-users .text-gray-400 {
15
+ color: #b8b8b8;
16
+ }
17
+
18
+ #enrolled-users input::placeholder {
19
+ color: #4a5568;
20
+ opacity: 1;
21
+ }
22
+
23
+ .dark #enrolled-users input::placeholder {
24
+ color: #b8b8b8;
25
+ }
26
+
27
+ #enrolled-users #user-search-dropdown {
28
+ position: absolute;
29
+ top: calc(100% + 0.25rem);
30
+ left: 0;
31
+ right: 0;
32
+ z-index: 20;
33
+ box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
34
+ }
35
+
36
+ #enrolled-users #user-search-results {
37
+ max-height: min(16rem, 40vh);
38
+ overflow-y: auto;
39
+ }
40
+
41
+ #enrolled-users #user-search-result-status {
42
+ padding: 0.5rem 0.75rem;
43
+ line-height: 1rem;
44
+ max-height: min(8rem, 30vh);
45
+ overflow-y: auto;
46
+ }
47
+
48
+ #enrolled-users #user-search-results [role="option"] > span:first-child {
49
+ min-width: 0;
50
+ overflow-wrap: anywhere;
51
+ }
52
+
53
+ #enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
54
+ background-color: #edf2f7;
55
+ }
56
+
57
+ .dark #enrolled-users #user-search-results [role="option"][aria-disabled="false"]:not([aria-selected="true"]):hover {
58
+ background-color: #3a3a3a;
59
+ }
60
+
61
+ #enrolled-users #user-search-results [role="option"][aria-selected="true"] {
62
+ background-color: #2563eb;
63
+ color: #ffffff;
64
+ }
65
+
66
+ #enrolled-users #user-search-results [role="option"][aria-selected="true"] .text-gray-500 {
67
+ color: inherit;
68
+ }
69
+
70
+ #enrolled-users #user-search-results [role="option"][aria-disabled="true"] {
71
+ cursor: default;
72
+ }
73
+
74
+ #enrolled-users #user-search-status {
75
+ height: 1.5rem;
76
+ line-height: 1.5rem;
77
+ margin-top: 0.25rem;
78
+ white-space: nowrap;
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ }
82
+
83
+ #enrolled-users #pending-user-slot {
84
+ margin-top: 0.75rem;
85
+ }
86
+
87
+ #enrolled-users #pending-user-changes:not([hidden]) {
88
+ display: grid;
89
+ grid-template-columns: minmax(0, 1fr) auto;
90
+ align-items: center;
91
+ column-gap: 0.5rem;
92
+ row-gap: 0.5rem;
93
+ }
94
+
95
+ #enrolled-users #pending-user-summary {
96
+ min-width: 0;
97
+ overflow-wrap: anywhere;
98
+ }
99
+
100
+ #enrolled-users #pending-user-actions {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 0.5rem;
104
+ }
105
+
106
+ #enrolled-users #pending-user-actions > button {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ min-height: 2.5rem;
111
+ }
112
+
113
+ #enrolled-users #review-user-changes {
114
+ border-color: currentColor;
115
+ }
116
+
117
+ #enrolled-users #pending-user-review {
118
+ position: absolute;
119
+ bottom: calc(100% + 0.5rem);
120
+ right: 0;
121
+ width: min(100%, 28rem);
122
+ z-index: 20;
123
+ box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
124
+ }
125
+
126
+ #enrolled-users #pending-user-details {
127
+ max-height: min(16rem, 50vh);
128
+ overflow-y: auto;
129
+ }
130
+
131
+ #enrolled-users #pending-user-details li {
132
+ display: flex;
133
+ align-items: baseline;
134
+ justify-content: space-between;
135
+ gap: 0.75rem;
136
+ margin-bottom: 0.25rem;
137
+ }
138
+
139
+ #enrolled-users #pending-user-details li span {
140
+ min-width: 0;
141
+ overflow-wrap: anywhere;
142
+ }
143
+
144
+ #enrolled-users button {
145
+ min-height: 24px;
146
+ }
147
+
148
+ #enrolled-users .enrolled-user-pagination {
149
+ margin-bottom: 0.5rem;
150
+ }
151
+
152
+ #enrolled-users .enrolled-user-pagination [aria-disabled="true"] {
153
+ opacity: 0.5;
154
+ }
155
+
156
+ @media (max-width: 480px) {
157
+ #enrolled-users #pending-user-changes:not([hidden]) {
158
+ grid-template-columns: minmax(0, 1fr);
159
+ }
160
+
161
+ #enrolled-users #pending-user-actions {
162
+ justify-self: center;
163
+ }
164
+
165
+ #enrolled-users #enrolled-users-table {
166
+ table-layout: fixed;
167
+ }
168
+
169
+ #enrolled-users #enrolled-users-table th,
170
+ #enrolled-users #enrolled-users-table td {
171
+ padding-left: 0.375rem;
172
+ padding-right: 0.375rem;
173
+ }
174
+
175
+ #enrolled-users #enrolled-users-table th:nth-child(2) {
176
+ width: 35%;
177
+ }
178
+
179
+ #enrolled-users #enrolled-users-table th:last-child {
180
+ width: 4rem;
181
+ }
182
+
183
+ #enrolled-users #enrolled-users-table td:nth-child(2) {
184
+ overflow-wrap: anywhere;
185
+ }
186
+
187
+ #enrolled-users .enrolled-user-pagination a {
188
+ padding-left: 0.5rem;
189
+ padding-right: 0.5rem;
190
+ }
191
+ }