DEV: Add disallowed_groups to theme/component settings (#41792)

Further following on from bee1be8599,
this brings greater parity between theme settings and site settings.

This commit adds a new `disallowed_groups` field to theme and component
settings, which allows theme authors to specify groups that cannot be
selected
by admins for a particular list type or object group type setting.

This is useful for cases where the group-based setting isn't usable
for a group like e.g. anonymous_users, no point allowing them for
a setting that requires a user to be logged in to take effect.

c.f.
https://meta.discourse.org/t/granular-group-based-permissions-for-anonymous-and-logged-in-users/402273/18?u=martin

### List groups

With `disallowed_groups: "4|5"` specified (`logged_in_users` &
`anonymous_users`)

<img width="734" height="450" alt="image"
src="https://github.com/user-attachments/assets/ed8d7338-65fa-44e6-8dce-7073a87076e6"
/>

### Object groups

With `disallowed_groups: "4|5"` specified (`logged_in_users` &
`anonymous_users`)

<img width="696" height="725" alt="image"
src="https://github.com/user-attachments/assets/1a9103d4-cf44-4038-9d62-3838cb779b49"
/>
This commit is contained in:
Martin Brennan
2026-07-20 09:28:52 +10:00
committed by GitHub
parent 4c7a4f637f
commit fc8df41adb
11 changed files with 256 additions and 1 deletions
@@ -9,6 +9,7 @@ class ThemeSettingsSerializer < ApplicationSerializer
:description,
:valid_values,
:list_type,
:disallowed_groups,
:resolve_group_membership,
:textarea,
:json_schema,
@@ -76,6 +77,14 @@ class ThemeSettingsSerializer < ApplicationSerializer
object.type == ThemeSetting.types[:list]
end
def disallowed_groups
object.disallowed_groups
end
def include_disallowed_groups?
object.disallowed_groups.present?
end
def textarea
object.textarea
end
@@ -9,6 +9,16 @@ export default class SchemaSettingTypeGroups extends SchemaSettingTypeModels {
type = "groups";
get groupChoices() {
const disallowed = (this.args.spec.disallowed_groups || "")
.split("|")
.filter(Boolean);
return (this.site.groups || []).filter(
(group) => !disallowed.includes(group.id.toString())
);
}
get groupChooserOptions() {
return {
clearable: !this.required,
@@ -19,7 +29,7 @@ export default class SchemaSettingTypeGroups extends SchemaSettingTypeModels {
<template>
<GroupChooser
@content={{this.site.groups}}
@content={{this.groupChoices}}
@value={{this.value}}
@onChange={{this.onInput}}
@options={{this.groupChooserOptions}}
@@ -1212,6 +1212,63 @@ module(
.hasText("You can only select 3 items.");
});
test("input fields of type groups filter disallowed groups", async function (assert) {
this.site.groups = [
{ id: 0, name: "everyone" },
{ id: 1, name: "admins" },
{ id: 2, name: "moderators" },
];
const setting = ThemeSettings.create({
setting: "objects_setting",
objects_schema: {
name: "something",
properties: {
group_ids: {
type: "groups",
disallowed_groups: "0|1",
},
},
},
value: [
{
group_ids: [],
},
],
});
await render(
<template>
<AdminSchemaSettingEditor
@id="1"
@setting={{setting}}
@schema={{setting.objects_schema}}
@routeToRedirect="adminCustomizeThemes.show"
/>
</template>
);
const inputFields = new InputFieldsFromDOM();
const groupsSelector = selectKit(
`${inputFields.fields.group_ids.selector} .select-kit`
);
await groupsSelector.expand();
assert.false(
groupsSelector.rowByValue("0").exists(),
"everyone is not in the list"
);
assert.false(
groupsSelector.rowByValue("1").exists(),
"admins is not in the list"
);
assert.true(
groupsSelector.rowByValue("2").exists(),
"moderators is in the list"
);
});
test("generic identifier is used when identifier is not specified in the schema", async function (assert) {
const setting = ThemeSettings.create({
setting: "objects_setting",
+4
View File
@@ -55,6 +55,10 @@ class ThemeSettingsManager
@opts[:refresh]
end
def disallowed_groups
@opts[:disallowed_groups]
end
def value=(new_value)
ensure_is_valid_value!(new_value)
value = new_value.to_s
+9
View File
@@ -8,4 +8,13 @@ class ThemeSettingsManager::List < ThemeSettingsManager
def resolve_group_membership?
@opts[:resolve_group_membership] && list_type == "group"
end
def value=(new_value)
if list_type == "group" && disallowed_groups.present?
disallowed_ids = disallowed_groups.to_s.split("|")
new_value = new_value.to_s.split("|").reject { |id| disallowed_ids.include?(id) }.join("|")
end
super
end
end
+44
View File
@@ -15,6 +15,7 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
def value=(objects)
objects = JSON.parse(objects) if objects.is_a?(::String)
objects = remove_disallowed_groups(objects)
ensure_is_valid_value!(objects)
objects = SchemaSettingsObjectValidator.normalize_uploads(schema:, objects:)
record = has_record? ? update_record!(json_value: objects) : create_record!(json_value: objects)
@@ -30,6 +31,12 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
SchemaSettingsObjectValidator.hydrate_uploads(schema:, objects:, cdn: true)
end
def remove_disallowed_groups(objects)
return objects if objects.blank?
remove_disallowed_groups_from_objects(objects.deep_dup, schema[:properties])
end
def categories(guardian)
category_ids = Set.new
@@ -46,4 +53,41 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
Category.secured(guardian).where(id: category_ids)
end
private
def remove_disallowed_groups_from_objects(objects, properties)
objects.each do |object|
properties.each do |property_name, property_attributes|
key = object_key(object, property_name)
next if key.nil?
case property_attributes[:type]
when "groups"
next if property_attributes[:disallowed_groups].blank?
disallowed_ids = property_attributes[:disallowed_groups].to_s.split("|").map(&:to_i)
object[key] = Array(object[key]).reject { |id| disallowed_ids.include?(id) }
when "objects"
nested_objects = object[key]
if nested_objects.is_a?(Array)
remove_disallowed_groups_from_objects(
nested_objects,
property_attributes[:schema][:properties],
)
end
end
end
end
objects
end
def object_key(object, property_name)
string_key = property_name.to_s
return string_key if object.key?(string_key)
symbol_key = property_name.to_sym
symbol_key if object.key?(symbol_key)
end
end
+1
View File
@@ -38,6 +38,7 @@ class ThemeSettingsParser
end
opts[:list_type] = raw_opts[:list_type] if raw_opts[:list_type]
opts[:disallowed_groups] = raw_opts[:disallowed_groups] if raw_opts[:disallowed_groups]
opts[:resolve_group_membership] = !!raw_opts[:resolve_group_membership]
opts[:textarea] = !!raw_opts[:textarea]
@@ -30,6 +30,53 @@ RSpec.describe ThemeSettingsManager::Objects do
expect(theme.reload.settings[:objects_setting].value).to eq(new_value)
end
it "removes disallowed group ids before saving groups properties" do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
objects_setting:
type: objects
default: []
schema:
name: section
properties:
group_ids:
type: groups
disallowed_groups: "#{Group::AUTO_GROUPS[:everyone]}|#{Group::AUTO_GROUPS[:admins]}"
links:
type: objects
schema:
name: link
properties:
group_ids:
type: groups
disallowed_groups: "#{Group::AUTO_GROUPS[:trust_level_0]}"
YAML
theme.save!
theme.settings[:objects_setting].value = [
{
"group_ids" => [
Group::AUTO_GROUPS[:everyone],
Group::AUTO_GROUPS[:admins],
Group::AUTO_GROUPS[:staff],
],
"links" => [
{
"group_ids" => [Group::AUTO_GROUPS[:trust_level_0], Group::AUTO_GROUPS[:trust_level_1]],
},
],
},
]
expect(theme.reload.settings[:objects_setting].value).to eq(
[
{
"group_ids" => [Group::AUTO_GROUPS[:staff]],
"links" => [{ "group_ids" => [Group::AUTO_GROUPS[:trust_level_1]] }],
},
],
)
end
it "raises the right error when there are objects which are not valid" do
new_value = [
{ "name" => "section 3", "links" => [{ "url" => "https://some.url.no.name" }] },
+17
View File
@@ -142,6 +142,23 @@ RSpec.describe ThemeSettingsManager do
expect(list_setting.list_type).to eq("compact")
end
it "removes disallowed group ids before saving group list settings" do
yaml = <<~YAML
groups_setting:
type: list
list_type: group
disallowed_groups: "0|1"
default: ""
YAML
theme.set_field(target: :settings, name: "yaml", value: yaml)
theme.save!
setting = theme.settings[:groups_setting]
setting.value = "0|1|2|3"
expect(theme.reload.settings[:groups_setting].value).to eq("2|3")
end
describe "#resolve_group_membership?" do
it "returns true when opted-in with list_type group" do
yaml = <<~YAML
+19
View File
@@ -87,6 +87,25 @@ RSpec.describe ThemeSettingsParser do
list_type = loader.find_by_name(:compact_list_setting)[:opts][:list_type]
expect(list_type).to eq("compact")
end
it "supports disallowed groups metadata" do
yaml = <<~YAML
groups_setting:
type: list
list_type: group
disallowed_groups: "0|1"
default: "2|3"
YAML
field = ThemeField.create!(theme_id: -1, target_id: 3, name: "yaml", value: yaml)
parsed = []
ThemeSettingsParser
.new(field)
.load { |name, default, type, opts| parsed << { name: name, opts: opts } }
setting = parsed.find { |parsed_setting| parsed_setting[:name] == :groups_setting }
expect(setting[:opts][:disallowed_groups]).to eq("0|1")
end
end
describe "resolve_group_membership" do
@@ -16,6 +16,44 @@ RSpec.describe ThemeSettingsSerializer do
expect(payload[:theme_settings][:objects_schema][:name]).to eq("section")
end
it "includes disallowed groups metadata for group properties" do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
objects_setting:
type: objects
default: []
schema:
name: section
properties:
group_ids:
type: groups
disallowed_groups: "0|1"
YAML
theme.save!
payload = ThemeSettingsSerializer.new(theme.reload.settings[:objects_setting]).as_json
expect(
payload[:theme_settings][:objects_schema][:properties][:group_ids][:disallowed_groups],
).to eq("0|1")
end
end
describe "#disallowed_groups" do
it "includes disallowed groups metadata for group list settings" do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
groups_setting:
type: list
list_type: group
disallowed_groups: "0|1"
default: "2|3"
YAML
theme.save!
payload = ThemeSettingsSerializer.new(theme.reload.settings[:groups_setting]).as_json
expect(payload[:theme_settings][:disallowed_groups]).to eq("0|1")
end
end
describe "#valid_values" do