1
0
mirror of synced 2026-05-22 15:03:15 +00:00

FEAT: add group level gating (#66)

Adds ability to gate topics by group membership. Shows separate subheading text and has input for a custom CTA button link.

Group gating can work independently of the category and tag gating, or in conjunction.
This commit is contained in:
Bannon Tanner
2026-03-19 14:10:52 -05:00
committed by GitHub
parent 6b0e482f6e
commit 6228f42ee6
7 changed files with 345 additions and 30 deletions
+4
View File
@@ -38,6 +38,10 @@
color: var(--primary-high);
}
}
&__group {
margin-bottom: 1rem;
}
}
}
@@ -14,6 +14,10 @@ export default class TopicInGatedCategory extends Component {
.map((id) => parseInt(id, 10))
.filter((id) => id);
enabledTags = settings.enabled_tags.split("|").filter(Boolean);
enabledGroups = settings.enabled_groups
.split("|")
.map((id) => parseInt(id, 10))
.filter((id) => !isNaN(id));
didInsertElement() {
super.didInsertElement(...arguments);
@@ -31,29 +35,41 @@ export default class TopicInGatedCategory extends Component {
}
recalculate() {
// do nothing if:
// a) topic does not have a category and does not have a gated tag
// b) component setting is empty
// c) user is logged in
// TODO(https://github.com/discourse/discourse/pull/36678): The string check can be
// removed using .discourse-compatibility once the PR is merged.
const gatedByTag = this.tags?.some((t) => {
const name = typeof t === "string" ? t : t.name;
return this.enabledTags.includes(name);
});
// user is in an enabled group — always bypass
if (
(!this.categoryId && !gatedByTag) ||
(this.enabledCategories.length === 0 && this.enabledTags.length === 0) ||
this.currentUser
this.currentUser?.groups?.some((g) => this.enabledGroups.includes(g.id))
) {
return;
}
if (this.enabledCategories.includes(this.categoryId) || gatedByTag) {
document.body.classList.add("topic-in-gated-category");
this.set("hidden", false);
const hasGroupGating = this.enabledGroups.length > 0;
const gatedByCategory = this.enabledCategories.includes(this.categoryId);
const gatedByTag = this.tags?.some((t) => {
const name = typeof t === "string" ? t : t.name;
return this.enabledTags.includes(name);
});
const hasAnyCategoryOrTag =
this.enabledCategories.length > 0 || this.enabledTags.length > 0;
if (!hasAnyCategoryOrTag && !hasGroupGating) {
return;
}
// when categories/tags are configured, topic must match one
if (hasAnyCategoryOrTag && !gatedByCategory && !gatedByTag) {
return;
}
// no groups configured — original behavior: any logged-in user bypasses
if (!hasGroupGating && this.currentUser) {
return;
}
document.body.classList.add("topic-in-gated-category");
this.set("hidden", false);
}
@computed("hidden")
@@ -61,6 +77,10 @@ export default class TopicInGatedCategory extends Component {
return !this.hidden;
}
get showGroupGate() {
return this.currentUser && this.enabledGroups.length > 0;
}
<template>
{{#if this.shouldShow}}
<div class="custom-gated-topic-container">
@@ -70,26 +90,42 @@ export default class TopicInGatedCategory extends Component {
</div>
<p class="custom-gated-topic-content--text">
{{i18n (themePrefix "subheading_text")}}
{{#if this.showGroupGate}}
{{i18n (themePrefix "group_subheading_text")}}
{{else}}
{{i18n (themePrefix "subheading_text")}}
{{/if}}
</p>
<div class="custom-gated-topic-content--cta">
<div class="custom-gated-topic-content--cta__signup">
<DButton
@action={{routeAction "showCreateAccount"}}
class="btn-primary btn-large sign-up-button"
@translatedLabel={{i18n (themePrefix "signup_cta_label")}}
/>
</div>
{{#if this.showGroupGate}}
<div class="custom-gated-topic-content--cta__group">
{{#if settings.group_custom_button_link}}
<DButton
@href={{settings.group_custom_button_link}}
class="btn-primary btn-large"
@translatedLabel={{i18n (themePrefix "group_cta_label")}}
/>
{{/if}}
</div>
{{else}}
<div class="custom-gated-topic-content--cta__signup">
<DButton
@action={{routeAction "showCreateAccount"}}
class="btn-primary btn-large sign-up-button"
@translatedLabel={{i18n (themePrefix "signup_cta_label")}}
/>
</div>
<div class="custom-gated-topic-content--cta__login">
<DButton
@action={{routeAction "showLogin"}}
@id="cta-login-link"
class="btn btn-text login-button"
@translatedLabel={{i18n (themePrefix "login_cta_label")}}
/>
</div>
<div class="custom-gated-topic-content--cta__login">
<DButton
@action={{routeAction "showLogin"}}
@id="cta-login-link"
class="btn btn-text login-button"
@translatedLabel={{i18n (themePrefix "login_cta_label")}}
/>
</div>
{{/if}}
</div>
</div>
</div>
+2
View File
@@ -5,3 +5,5 @@ en:
subheading_text: "Create an account to view this content"
signup_cta_label: "Sign Up"
login_cta_label: "Already have an account? Sign in"
group_subheading_text: "You need to be a group member to view this content"
group_cta_label: "Show Group"
+9
View File
@@ -8,3 +8,12 @@ enabled_tags:
list_type: tag
default: ""
description: "Choose which tags that users need to sign up for. The default is 'gated'."
enabled_groups:
type: list
list_type: group
default: ""
description: "Choose which groups users need to be a member of to view the content. Leave empty to allow all logged-in users."
group_custom_button_link:
type: string
default: ""
description: "Optionally specify a custom URL for the call-to-action button for groups."
@@ -0,0 +1,101 @@
# frozen_string_literal: true
require_relative "page_objects/components/gated_topic"
RSpec.describe "Gated topics with groups" do
fab!(:category)
fab!(:topic) { Fabricate(:topic, category:) }
fab!(:post) { Fabricate(:post, topic:) }
fab!(:group) { Fabricate(:group, name: "premium") }
fab!(:member, :user)
fab!(:non_member, :user)
let!(:theme) { upload_theme_component }
let(:gated_topic) { PageObjects::Components::GatedTopic.new }
before do
group.add(member)
theme.update_setting(:enabled_categories, category.id.to_s)
theme.update_setting(:enabled_groups, group.id.to_s)
theme.save!
end
it "does not show gate for user in allowed group" do
sign_in(member)
visit(topic.url)
expect(gated_topic).to have_no_gate
end
it "shows group gate without CTA button for user not in allowed group" do
sign_in(non_member)
visit(topic.url)
expect(gated_topic).to have_gate
expect(gated_topic).to have_group_gate
expect(gated_topic).to have_no_group_cta_button
end
it "shows anonymous gate (not group gate) for anonymous users" do
visit(topic.url)
expect(gated_topic).to have_gate
expect(gated_topic).to have_signup_gate
end
context "when no groups are configured" do
before do
theme.update_setting(:enabled_groups, "")
theme.save!
end
it "does not show gate for any logged-in user" do
sign_in(non_member)
visit(topic.url)
expect(gated_topic).to have_no_gate
end
end
context "with multiple groups configured" do
fab!(:second_group) { Fabricate(:group, name: "vip") }
fab!(:second_group_member, :user)
before do
second_group.add(second_group_member)
theme.update_setting(:enabled_groups, "#{group.id}|#{second_group.id}")
theme.save!
end
it "does not show gate for user in one of the allowed groups" do
sign_in(second_group_member)
visit(topic.url)
expect(gated_topic).to have_no_gate
end
end
context "with only groups configured (no categories or tags)" do
before do
theme.update_setting(:enabled_categories, "")
theme.save!
end
it "shows gate on any topic for user not in group" do
sign_in(non_member)
visit(topic.url)
expect(gated_topic).to have_gate
expect(gated_topic).to have_group_gate
end
end
context "with custom button link" do
before do
theme.update_setting(:group_custom_button_link, "https://example.com/subscribe")
theme.save!
end
it "shows group gate with CTA button linking to custom URL" do
sign_in(non_member)
visit(topic.url)
expect(gated_topic).to have_gate
expect(gated_topic).to have_group_cta_button
expect(gated_topic).to have_group_cta_href("https://example.com/subscribe")
end
end
end
@@ -12,6 +12,26 @@ module PageObjects
def has_no_gate?
has_no_css?(SELECTOR)
end
def has_signup_gate?
has_css?("#{SELECTOR} .custom-gated-topic-content--cta__signup")
end
def has_group_gate?
has_css?("#{SELECTOR} .custom-gated-topic-content--cta__group")
end
def has_group_cta_button?
has_css?("#{SELECTOR} .custom-gated-topic-content--cta__group .btn-primary")
end
def has_no_group_cta_button?
has_no_css?("#{SELECTOR} .custom-gated-topic-content--cta__group .btn-primary")
end
def has_group_cta_href?(href)
has_css?("#{SELECTOR} .custom-gated-topic-content--cta__group a[href='#{href}']")
end
end
end
end
+143
View File
@@ -74,3 +74,146 @@ acceptance("Gated Topics - Logged In", function (needs) {
);
});
});
acceptance("Gated Topics - User in Allowed Group", function (needs) {
needs.user({
groups: [{ id: 42, name: "premium" }],
});
needs.settings({ tagging_enabled: true });
needs.hooks.beforeEach(function () {
settings.enabled_categories = "2";
settings.enabled_groups = "42";
});
needs.hooks.afterEach(function () {
settings.enabled_categories = "";
settings.enabled_groups = "";
});
test("no gate shown for user in allowed group", async function (assert) {
await visit("/t/internationalization-localization/280");
assert
.dom(".custom-gated-topic-content")
.doesNotExist("gate not shown when user is in the allowed group");
});
});
acceptance("Gated Topics - User NOT in Allowed Group", function (needs) {
needs.user({
groups: [{ id: 99, name: "other" }],
});
needs.settings({ tagging_enabled: true });
needs.hooks.beforeEach(function () {
settings.enabled_categories = "2";
settings.enabled_groups = "42";
});
needs.hooks.afterEach(function () {
settings.enabled_categories = "";
settings.enabled_groups = "";
});
test("gate shown with group subheading and no CTA button", async function (assert) {
await visit("/t/internationalization-localization/280");
assert
.dom(".custom-gated-topic-content")
.exists("gate shown when user is not in the allowed group");
assert
.dom(".custom-gated-topic-content--cta__group .btn-primary")
.doesNotExist(
"no group CTA button when group_custom_button_link is empty"
);
assert
.dom(".custom-gated-topic-content--cta__signup")
.doesNotExist("signup CTA not shown for logged-in user");
});
});
acceptance(
"Gated Topics - User NOT in Group with Custom Link",
function (needs) {
needs.user({
groups: [{ id: 99, name: "other" }],
});
needs.settings({ tagging_enabled: true });
needs.hooks.beforeEach(function () {
settings.enabled_categories = "2";
settings.enabled_groups = "42";
settings.group_custom_button_link = "https://example.com/subscribe";
});
needs.hooks.afterEach(function () {
settings.enabled_categories = "";
settings.enabled_groups = "";
settings.group_custom_button_link = "";
});
test("gate shown with custom CTA button", async function (assert) {
await visit("/t/internationalization-localization/280");
assert
.dom(".custom-gated-topic-content--cta__group .btn-primary")
.exists("group CTA button is shown when custom link is set");
assert
.dom(".custom-gated-topic-content--cta__group .btn-primary")
.hasAttribute(
"href",
"https://example.com/subscribe",
"CTA uses custom button link"
);
});
}
);
acceptance("Gated Topics - User in One of Multiple Groups", function (needs) {
needs.user({
groups: [{ id: 99, name: "vip" }],
});
needs.settings({ tagging_enabled: true });
needs.hooks.beforeEach(function () {
settings.enabled_categories = "2";
settings.enabled_groups = "42|99";
});
needs.hooks.afterEach(function () {
settings.enabled_categories = "";
settings.enabled_groups = "";
});
test("no gate shown for user in one of multiple allowed groups", async function (assert) {
await visit("/t/internationalization-localization/280");
assert
.dom(".custom-gated-topic-content")
.doesNotExist("gate not shown when user is in one of the allowed groups");
});
});
acceptance(
"Gated Topics - Groups Only (no categories or tags)",
function (needs) {
needs.user({
groups: [{ id: 99, name: "other" }],
});
needs.hooks.beforeEach(function () {
settings.enabled_groups = "42";
});
needs.hooks.afterEach(function () {
settings.enabled_groups = "";
});
test("gate shown on any topic when only groups configured", async function (assert) {
await visit("/t/internationalization-localization/280");
assert
.dom(".custom-gated-topic-content")
.exists("gate shown even without category/tag settings");
});
}
);