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);
|
color: var(--primary-high);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default class TopicInGatedCategory extends Component {
|
|||||||
.map((id) => parseInt(id, 10))
|
.map((id) => parseInt(id, 10))
|
||||||
.filter((id) => id);
|
.filter((id) => id);
|
||||||
enabledTags = settings.enabled_tags.split("|").filter(Boolean);
|
enabledTags = settings.enabled_tags.split("|").filter(Boolean);
|
||||||
|
enabledGroups = settings.enabled_groups
|
||||||
|
.split("|")
|
||||||
|
.map((id) => parseInt(id, 10))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
|
||||||
didInsertElement() {
|
didInsertElement() {
|
||||||
super.didInsertElement(...arguments);
|
super.didInsertElement(...arguments);
|
||||||
@@ -31,29 +35,41 @@ export default class TopicInGatedCategory extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recalculate() {
|
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
|
// TODO(https://github.com/discourse/discourse/pull/36678): The string check can be
|
||||||
// removed using .discourse-compatibility once the PR is merged.
|
// 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 (
|
if (
|
||||||
(!this.categoryId && !gatedByTag) ||
|
this.currentUser?.groups?.some((g) => this.enabledGroups.includes(g.id))
|
||||||
(this.enabledCategories.length === 0 && this.enabledTags.length === 0) ||
|
|
||||||
this.currentUser
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.enabledCategories.includes(this.categoryId) || gatedByTag) {
|
const hasGroupGating = this.enabledGroups.length > 0;
|
||||||
document.body.classList.add("topic-in-gated-category");
|
const gatedByCategory = this.enabledCategories.includes(this.categoryId);
|
||||||
this.set("hidden", false);
|
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")
|
@computed("hidden")
|
||||||
@@ -61,6 +77,10 @@ export default class TopicInGatedCategory extends Component {
|
|||||||
return !this.hidden;
|
return !this.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showGroupGate() {
|
||||||
|
return this.currentUser && this.enabledGroups.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.shouldShow}}
|
{{#if this.shouldShow}}
|
||||||
<div class="custom-gated-topic-container">
|
<div class="custom-gated-topic-container">
|
||||||
@@ -70,26 +90,42 @@ export default class TopicInGatedCategory extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="custom-gated-topic-content--text">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="custom-gated-topic-content--cta">
|
<div class="custom-gated-topic-content--cta">
|
||||||
<div class="custom-gated-topic-content--cta__signup">
|
{{#if this.showGroupGate}}
|
||||||
<DButton
|
<div class="custom-gated-topic-content--cta__group">
|
||||||
@action={{routeAction "showCreateAccount"}}
|
{{#if settings.group_custom_button_link}}
|
||||||
class="btn-primary btn-large sign-up-button"
|
<DButton
|
||||||
@translatedLabel={{i18n (themePrefix "signup_cta_label")}}
|
@href={{settings.group_custom_button_link}}
|
||||||
/>
|
class="btn-primary btn-large"
|
||||||
</div>
|
@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">
|
<div class="custom-gated-topic-content--cta__login">
|
||||||
<DButton
|
<DButton
|
||||||
@action={{routeAction "showLogin"}}
|
@action={{routeAction "showLogin"}}
|
||||||
@id="cta-login-link"
|
@id="cta-login-link"
|
||||||
class="btn btn-text login-button"
|
class="btn btn-text login-button"
|
||||||
@translatedLabel={{i18n (themePrefix "login_cta_label")}}
|
@translatedLabel={{i18n (themePrefix "login_cta_label")}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ en:
|
|||||||
subheading_text: "Create an account to view this content"
|
subheading_text: "Create an account to view this content"
|
||||||
signup_cta_label: "Sign Up"
|
signup_cta_label: "Sign Up"
|
||||||
login_cta_label: "Already have an account? Sign in"
|
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
|
list_type: tag
|
||||||
default: ""
|
default: ""
|
||||||
description: "Choose which tags that users need to sign up for. The default is 'gated'."
|
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?
|
def has_no_gate?
|
||||||
has_no_css?(SELECTOR)
|
has_no_css?(SELECTOR)
|
||||||
end
|
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
|
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