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:
@@ -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,36 +35,52 @@ 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) {
|
||||
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")
|
||||
get shouldShow() {
|
||||
return !this.hidden;
|
||||
}
|
||||
|
||||
get showGroupGate() {
|
||||
return this.currentUser && this.enabledGroups.length > 0;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.shouldShow}}
|
||||
<div class="custom-gated-topic-container">
|
||||
@@ -70,10 +90,25 @@ export default class TopicInGatedCategory extends Component {
|
||||
</div>
|
||||
|
||||
<p class="custom-gated-topic-content--text">
|
||||
{{#if this.showGroupGate}}
|
||||
{{i18n (themePrefix "group_subheading_text")}}
|
||||
{{else}}
|
||||
{{i18n (themePrefix "subheading_text")}}
|
||||
{{/if}}
|
||||
</p>
|
||||
|
||||
<div class="custom-gated-topic-content--cta">
|
||||
{{#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"}}
|
||||
@@ -90,6 +125,7 @@ export default class TopicInGatedCategory extends Component {
|
||||
@translatedLabel={{i18n (themePrefix "login_cta_label")}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user