FEATURE: Allow admins to see users' upcoming change stats (#36768)

Allows admins and moderators to see users' upcoming changes
and the reason the user has that change enabled, or not enabled. It is
added to the existing page at `/admin/users/:id/:username`

It could either be because the change is enabled for Everyone, or
only for specific groups:


We give a shortcut link to each group so the admin can inspect it.
This commit is contained in:
Martin Brennan
2026-01-07 17:34:10 +10:00
committed by GitHub
parent 121eb1b0f8
commit df8391e3f3
13 changed files with 555 additions and 4 deletions
@@ -1025,6 +1025,11 @@
}
}
.user-upcoming-changes-table {
margin-top: var(--space-4);
width: 100%;
}
.paginated-topics-list {
.user-content {
width: 100%;
+4
View File
@@ -1963,6 +1963,10 @@ class User < ActiveRecord::Base
UpcomingChanges.enabled_for_user?(upcoming_change, self)
end
def upcoming_change_stats(acting_guardian)
UpcomingChanges.stats_for_user(user: self, acting_guardian: acting_guardian)
end
protected
def badge_grant
@@ -37,7 +37,8 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:api_key_count,
:external_ids,
:similar_users_count,
:latest_export
:latest_export,
:upcoming_changes_stats
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
@@ -183,4 +184,12 @@ class AdminDetailedUserSerializer < AdminUserSerializer
UserExportSerializer.new(export, scope:).as_json if export
end
def upcoming_changes_stats
object.upcoming_change_stats(scope)
end
def include_upcoming_changes_stats?
SiteSetting.enable_upcoming_changes && scope.is_staff?
end
end
+18 -2
View File
@@ -1482,7 +1482,17 @@ en:
private_message: "Message"
private_messages: "Messages"
upcoming_changes:
title: "Upcoming change enabled"
title: "Upcoming changes"
for_user:
upcoming_change: "Upcoming change"
enabled: "Enabled?"
why: "Why?"
why_reasons:
not_in_specific_groups: "The user is not in the specific groups required for this change"
in_specific_groups: "The user is in the specific groups required for this change"
enabled_for_everyone: "This change is enabled for all members"
enabled_for_no_one: "This change is disabled for all members"
user_notifications:
filters:
filter_by: "Filter By"
@@ -1826,7 +1836,8 @@ en:
tags: "Tags"
interface: "Interface"
apps: "Apps"
navigation_menu: "Navigation Menu"
navigation_menu: "Navigation menu"
upcoming_changes: "Upcoming changes"
change_password:
success: "(email sent)"
@@ -8144,6 +8155,11 @@ en:
approve_bulk_success: "Success! All selected users have been approved and notified."
time_read: "Read Time"
post_edits_count: "Post Edits"
upcoming_changes:
title: "User upcoming changes"
description: "To modify groups or to enable or disable upcoming changes, please visit the <a href='%{basePath}/admin/config/upcoming-changes'>upcoming changes config page</a>."
filter_placeholder: "Filter upcoming changes by name or description"
filter_no_results: "No upcoming changes found"
exports:
title: User exports
download:
@@ -0,0 +1,110 @@
import Component from "@glimmer/component";
import { array } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import AdminFilterControls from "discourse/admin/components/admin-filter-controls";
import { UPCOMING_CHANGES_USER_ENABLED_REASONS } from "discourse/lib/constants";
import { bind } from "discourse/lib/decorators";
import getUrl from "discourse/lib/get-url";
import { escapeExpression } from "discourse/lib/utilities";
import { and, eq } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
export default class AdminUserUpcomingChanges extends Component {
@bind
reasonKey(reason) {
return `user.upcoming_changes.why_reasons.${reason}`;
}
@bind
getGroupLinks(groups) {
return (
"(" +
groups
.map((group) => {
return `<a href="${getUrl(`/groups/${encodeURIComponent(group)}`)}">${escapeExpression(group)}</a>`;
})
.join(", ") +
")"
);
}
<template>
<AdminFilterControls
@array={{@user.upcoming_changes_stats}}
@searchableProps={{array "humanized_name" "description"}}
@inputPlaceholder={{i18n
"admin.user.upcoming_changes.filter_placeholder"
}}
@noResultsMessage={{i18n "admin.user.upcoming_changes.filter_no_results"}}
>
<:content as |filteredChanges|>
<table class="d-table user-upcoming-changes-table">
<thead class="d-table__header">
<th>{{i18n "user.upcoming_changes.for_user.upcoming_change"}}</th>
<th>{{i18n "user.upcoming_changes.for_user.enabled"}}</th>
<th>{{i18n "user.upcoming_changes.for_user.why"}}</th>
</thead>
<tbody class="d-table__body">
{{#each filteredChanges as |upcomingChange|}}
<tr
class="d-table__row"
data-upcoming-change-name={{upcomingChange.name}}
>
<td class="d-table__cell --overview">
<div class="d-table__overview-name">
{{upcomingChange.humanized_name}}
</div>
{{#if upcomingChange.description}}
<div class="d-table__overview-about">
{{upcomingChange.description}}
</div>
{{/if}}
</td>
<td class="d-table__cell">
<div class="d-admin-row__mobile-label">
{{i18n "user.upcoming_changes.for_user.enabled"}}
</div>
<span class="upcoming-change-enabled-status">
{{if
upcomingChange.enabled
(i18n "yes_value")
(i18n "no_value")
}}
</span>
</td>
<td class="d-table__cell">
<div class="d-admin-row__mobile-label">
{{i18n "user.upcoming_changes.for_user.why"}}
</div>
<span class="upcoming-change-reason">
{{i18n
(this.reasonKey upcomingChange.reason)
username=@user.username
}}
</span>
{{#if
(and
(eq
upcomingChange.reason
UPCOMING_CHANGES_USER_ENABLED_REASONS.in_specific_groups
)
upcomingChange.specific_groups.length
)
}}
<span class="upcoming-change-groups">
{{htmlSafe
(this.getGroupLinks upcomingChange.specific_groups)
}}
</span>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</:content>
</AdminFilterControls>
</template>
}
@@ -22,6 +22,7 @@ export default class AdminUserIndexController extends Controller {
@service router;
@service dialog;
@service adminTools;
@service siteSettings;
@service modal;
originalPrimaryGroupId = null;
@@ -3,11 +3,13 @@ import { LinkTo } from "@ember/routing";
import { htmlSafe } from "@ember/template";
import AdminEditableField from "discourse/admin/components/admin-editable-field";
import AdminUserExportsTable from "discourse/admin/components/admin-user-exports-table";
import AdminUserUpcomingChanges from "discourse/admin/components/admin-user-upcoming-changes";
import IpLookup from "discourse/admin/components/ip-lookup";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import avatar from "discourse/helpers/avatar";
import basePath from "discourse/helpers/base-path";
import icon from "discourse/helpers/d-icon";
import formatDate from "discourse/helpers/format-date";
import formatDuration from "discourse/helpers/format-duration";
@@ -931,6 +933,22 @@ export default <template>
</section>
{{/if}}
{{#if
(and
@controller.currentUser.staff
@controller.model.upcoming_changes_stats
@controller.siteSettings.enable_upcoming_changes
)
}}
<section class="details">
<h1>{{i18n "admin.user.upcoming_changes.title"}}</h1>
<p>{{htmlSafe
(i18n "admin.user.upcoming_changes.description" basePath=basePath)
}}</p>
<AdminUserUpcomingChanges @user={{@controller.model}} />
</section>
{{/if}}
{{#if @controller.currentUser.admin}}
<AdminUserExportsTable @model={{@controller.model}} />
{{/if}}
+7
View File
@@ -115,6 +115,13 @@ export const INVITE_DESCRIPTION_MAX_LENGTH = 100;
export const USER_OPTION_COMPOSITION_MODES = { markdown: 0, rich: 1 };
export const UPCOMING_CHANGES_USER_ENABLED_REASONS = {
enabled_for_everyone: "enabled_for_everyone",
enabled_for_no_one: "enabled_for_no_one",
in_specific_groups: "in_specific_groups",
not_in_specific_groups: "not_in_specific_groups",
};
export const INTERFACE_COLOR_MODES = {
AUTO: 1,
LIGHT: 2,
+2
View File
@@ -97,6 +97,8 @@ task "javascript:update_constants" => :environment do
export const USER_OPTION_COMPOSITION_MODES = #{UserOption.composition_mode_types.to_json};
export const UPCOMING_CHANGES_USER_ENABLED_REASONS = #{UpcomingChanges.user_enabled_reasons.to_json};
export const INTERFACE_COLOR_MODES = {
AUTO: #{UserOption::AUTO_MODE},
LIGHT: #{UserOption::LIGHT_MODE},
+51 -1
View File
@@ -1,9 +1,19 @@
# frozen_string_literal: true
module UpcomingChanges
def self.user_enabled_reasons
@user_enabled_reasons ||=
::Enum.new(
enabled_for_everyone: :enabled_for_everyone,
enabled_for_no_one: :enabled_for_no_one,
in_specific_groups: :in_specific_groups,
not_in_specific_groups: :not_in_specific_groups,
)
end
def self.statuses
@statuses ||=
Enum.new(experimental: 0, alpha: 100, beta: 200, stable: 300, permanent: 500, never: 9999)
::Enum.new(experimental: 0, alpha: 100, beta: 200, stable: 300, permanent: 500, never: 9999)
end
def self.image_exists?(change_setting_name)
@@ -84,4 +94,44 @@ module UpcomingChanges
setting_enabled
end
def self.stats_for_user(user:, acting_guardian:)
guardian_visible_group_ids = Group.visible_groups(acting_guardian.user).pluck(:id)
user_belonging_to_group_ids = user.belonging_to_group_ids
SiteSetting.upcoming_change_site_settings.map do |upcoming_change|
enabled = user.upcoming_change_enabled?(upcoming_change)
has_groups = UpcomingChanges.has_groups?(upcoming_change)
specific_groups = []
reason =
if has_groups
visible_group_ids =
UpcomingChanges.group_ids_for(upcoming_change) & guardian_visible_group_ids &
user_belonging_to_group_ids
specific_groups = Group.where(id: visible_group_ids).pluck(:name)
if enabled
UpcomingChanges.user_enabled_reasons[:in_specific_groups]
else
UpcomingChanges.user_enabled_reasons[:not_in_specific_groups]
end
else
if enabled
UpcomingChanges.user_enabled_reasons[:enabled_for_everyone]
else
UpcomingChanges.user_enabled_reasons[:enabled_for_no_one]
end
end
{
name: upcoming_change,
humanized_name: SiteSetting.humanized_name(upcoming_change),
description: SiteSetting.description(upcoming_change),
enabled: enabled,
specific_groups: specific_groups,
reason: reason,
}
end
end
end
+164
View File
@@ -3786,4 +3786,168 @@ RSpec.describe User do
expect(user.effective_locale).to eq(SiteSetting.default_locale)
end
end
describe ".upcoming_change_stats" do
fab!(:target_user, :user)
fab!(:guardian_user, :user)
fab!(:admin_user, :admin)
fab!(:group1) { Fabricate(:group, name: "test_group_1") }
fab!(:group2) { Fabricate(:group, name: "test_group_2") }
fab!(:hidden_group) do
Fabricate(:group, name: "hidden_group", visibility_level: Group.visibility_levels[:owners])
end
let(:guardian) { Guardian.new(guardian_user) }
let(:admin_guardian) { Guardian.new(admin_user) }
before do
SiteSetting.enable_upcoming_changes = true
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "feature,all_members",
status: :beta,
impact_type: "feature",
impact_role: "all_members",
},
},
)
end
context "when the change is enabled for all users" do
before { SiteSetting.enable_upload_debug_mode = true }
it "returns enabled as true and reason as enabled_for_everyone" do
stats = target_user.upcoming_change_stats(guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(true)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:enabled_for_everyone],
)
expect(change_stat[:specific_groups]).to eq([])
end
end
context "when the change is enabled for no users" do
before { SiteSetting.enable_upload_debug_mode = false }
it "returns enabled as false and reason as enabled_for_no_one" do
stats = target_user.upcoming_change_stats(guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(false)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:enabled_for_no_one],
)
expect(change_stat[:specific_groups]).to eq([])
end
end
context "when the change is enabled for specific groups" do
before do
SiteSetting.enable_upload_debug_mode = true
Fabricate(
:site_setting_group,
name: "enable_upload_debug_mode",
group_ids: "#{group1.id}|#{group2.id}",
)
end
context "when the target user belongs to one of those groups" do
before { group1.add(target_user) }
it "returns enabled as true and reason as in_specific_groups" do
stats = target_user.upcoming_change_stats(admin_guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(true)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:in_specific_groups],
)
expect(change_stat[:specific_groups]).to contain_exactly("test_group_1")
end
end
context "when the target user belongs to multiple groups" do
before do
group1.add(target_user)
group2.add(target_user)
end
it "returns enabled as true with all groups in specific_groups" do
stats = target_user.upcoming_change_stats(admin_guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(true)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:in_specific_groups],
)
expect(change_stat[:specific_groups]).to contain_exactly("test_group_1", "test_group_2")
end
end
context "when the target user does NOT belong to any of those groups" do
it "returns enabled as false and reason as not_in_specific_groups" do
stats = target_user.upcoming_change_stats(admin_guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(false)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:not_in_specific_groups],
)
expect(change_stat[:specific_groups]).to eq([])
end
end
end
context "when guardian user visibility is restricted" do
before do
SiteSetting.enable_upload_debug_mode = true
group1.add(target_user)
hidden_group.add(target_user)
Fabricate(
:site_setting_group,
name: "enable_upload_debug_mode",
group_ids: "#{group1.id}|#{hidden_group.id}",
)
end
it "only shows groups the guardian user is allowed to see in specific_groups" do
stats = target_user.upcoming_change_stats(guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(true)
expect(change_stat[:reason]).to eq(
UpcomingChanges.user_enabled_reasons[:in_specific_groups],
)
expect(change_stat[:specific_groups]).to contain_exactly("test_group_1")
expect(change_stat[:specific_groups]).not_to include("hidden_group")
end
it "shows all groups when guardian is admin" do
stats = target_user.upcoming_change_stats(admin_guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:enabled]).to be(true)
expect(change_stat[:specific_groups]).to contain_exactly("test_group_1", "hidden_group")
end
end
describe "metadata fields" do
before { SiteSetting.enable_upload_debug_mode = true }
it "includes correct name, humanized_name, and description" do
stats = target_user.upcoming_change_stats(guardian)
change_stat = stats.find { |s| s[:name] == :enable_upload_debug_mode }
expect(change_stat[:name]).to eq(:enable_upload_debug_mode)
expect(change_stat[:humanized_name]).to eq(
SiteSetting.humanized_name(:enable_upload_debug_mode),
)
expect(change_stat[:description]).to eq(SiteSetting.description(:enable_upload_debug_mode))
end
end
end
end
+112
View File
@@ -61,6 +61,118 @@ describe "Admin User Page", type: :system do
expect(page.title).to eq("#{user.username} - Users - Admin - Discourse")
end
describe "the upcoming changes section" do
fab!(:group1) { Fabricate(:group, name: "test_group_1") }
fab!(:group2) { Fabricate(:group, name: "test_group_2") }
before do
SiteSetting.enable_upcoming_changes = true
mock_upcoming_change_metadata(
{
enable_upload_debug_mode: {
impact: "feature,all_members",
status: :beta,
impact_type: "feature",
impact_role: "all_members",
},
},
)
end
context "when the change is enabled for everyone" do
before { SiteSetting.enable_upload_debug_mode = true }
it "displays the upcoming change with enabled status and correct reason" do
admin_user_page.visit(user)
expect(admin_user_page).to have_upcoming_change("enable_upload_debug_mode")
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to be_enabled
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to have_reason(
"enabled_for_everyone",
)
expect(
admin_user_page.upcoming_change("enable_upload_debug_mode"),
).to have_no_specific_groups
end
end
context "when the change is disabled for everyone" do
before { SiteSetting.enable_upload_debug_mode = false }
it "displays the upcoming change with disabled status and correct reason" do
admin_user_page.visit(user)
expect(admin_user_page).to have_upcoming_change("enable_upload_debug_mode")
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to be_disabled
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to have_reason(
"enabled_for_no_one",
)
expect(
admin_user_page.upcoming_change("enable_upload_debug_mode"),
).to have_no_specific_groups
end
end
context "when the change is enabled for specific groups" do
before do
SiteSetting.enable_upload_debug_mode = true
Fabricate(
:site_setting_group,
name: "enable_upload_debug_mode",
group_ids: "#{group1.id}|#{group2.id}",
)
end
context "when the user belongs to one of those groups" do
before { group1.add(user) }
it "displays the upcoming change with enabled status, correct reason, and specific groups" do
admin_user_page.visit(user)
expect(admin_user_page).to have_upcoming_change("enable_upload_debug_mode")
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to be_enabled
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to have_reason(
"in_specific_groups",
)
expect(
admin_user_page.upcoming_change("enable_upload_debug_mode"),
).to have_specific_groups(["test_group_1"])
end
end
context "when the user belongs to multiple groups" do
before do
group1.add(user)
group2.add(user)
end
it "displays the upcoming change with all groups" do
admin_user_page.visit(user)
expect(admin_user_page).to have_upcoming_change("enable_upload_debug_mode")
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to be_enabled
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to have_reason(
"in_specific_groups",
)
expect(
admin_user_page.upcoming_change("enable_upload_debug_mode"),
).to have_specific_groups(%w[test_group_1 test_group_2])
end
end
context "when the user does not belong to any of those groups" do
it "displays the upcoming change with disabled status, correct reason, and no specific groups" do
admin_user_page.visit(user)
expect(admin_user_page).to have_upcoming_change("enable_upload_debug_mode")
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to be_disabled
expect(admin_user_page.upcoming_change("enable_upload_debug_mode")).to have_reason(
"not_in_specific_groups",
)
expect(
admin_user_page.upcoming_change("enable_upload_debug_mode"),
).to have_no_specific_groups
end
end
end
end
describe "the suspend user modal" do
it "displays the list of users who share the same IP but are not mods or admins" do
admin_user_page.click_suspend_button
@@ -55,6 +55,59 @@ module PageObjects
def similar_users_warning
find(".penalty-similar-users .alert-warning")["innerHTML"]
end
class UpcomingChangeRow < PageObjects::Components::Base
attr_reader :element
def initialize(element)
@element = element
end
def enabled?
expect(element.find(".upcoming-change-enabled-status")).to have_content(
I18n.t("js.yes_value"),
)
end
def disabled?
expect(element.find(".upcoming-change-enabled-status")).to have_content(
I18n.t("js.no_value"),
)
end
def has_reason?(reason_key)
expected_text = I18n.t("js.user.upcoming_changes.why_reasons.#{reason_key}")
expect(element.find(".upcoming-change-reason")).to have_content(expected_text)
end
def specific_groups
within element.find(".upcoming-change-groups") do
all("a").map(&:text).sort
end
end
def has_specific_groups?(group_names)
specific_groups == group_names.sort
end
def has_no_specific_groups?
expect(element).to have_no_css(".upcoming-change-groups")
end
end
def has_upcoming_change?(change_name)
has_css?(
".user-upcoming-changes-table .d-table__row[data-upcoming-change-name='#{change_name}']",
)
end
def upcoming_change(change_name)
row =
find(
".user-upcoming-changes-table .d-table__row[data-upcoming-change-name='#{change_name}']",
)
UpcomingChangeRow.new(row)
end
end
end
end