diff --git a/README.md b/README.md index 557b36e..aa705f2 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,13 @@ **Plugin Summary** -For more information, please see: **url to meta topic** +> [!WARNING] +> Currently only works with `invite only` setups. + +This plugin provides functionality to generate anonymous usernames for users during sign-up. + +- [x] Generates random usernames based on a predefined list of words(`random words list` + random number). +- [x] Option to restrict users to only use generated usernames (`only generated usernames` setting). + +> [!IMPORTANT] +> Ensure to have `full_name_requirement` setting as `"required_at_signup"`. diff --git a/app/controllers/discourse_anon_usernames/examples_controller.rb b/app/controllers/discourse_anon_usernames/examples_controller.rb index abdb8bd..35b1bf7 100644 --- a/app/controllers/discourse_anon_usernames/examples_controller.rb +++ b/app/controllers/discourse_anon_usernames/examples_controller.rb @@ -3,9 +3,5 @@ module ::DiscourseAnonUsernames class ExamplesController < ::ApplicationController requires_plugin PLUGIN_NAME - - def index - render json: { hello: "world" } - end end end diff --git a/assets/javascripts/discourse/components/randomizer-button.gjs b/assets/javascripts/discourse/components/randomizer-button.gjs new file mode 100644 index 0000000..20daacb --- /dev/null +++ b/assets/javascripts/discourse/components/randomizer-button.gjs @@ -0,0 +1,53 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; + +const getFirstName = (fullName) => fullName.split(" ")[0]; + +export default class RandomizerButton extends Component { + @service siteSettings; + + get randomWordsList() { + return this.siteSettings.random_words_list.split("|") || []; + } + + fetchRandomWord() { + if (this.randomWordsList.length === 0) { + return ""; + } + return this.randomWordsList[ + Math.floor(Math.random() * this.randomWordsList.length) + ]; + } + + @action + async generate() { + const randomizeFrom = getFirstName(this.args.randomizeFrom); + const randomWord = this.fetchRandomWord(); + + this.args.onGenerate({ + target: { + value: randomizeFrom + randomWord + Math.floor(Math.random() * 1000), + }, + }); + } + + get canGenerate() { + return ( + this.args.randomizeFrom && + this.args.randomizeFrom.length !== 0 && + getFirstName(this.args.randomizeFrom).trim() !== + this.args.randomizeFrom.trim() + ); + } + + +} diff --git a/assets/javascripts/discourse/initializers/sign-up-username-override.gjs b/assets/javascripts/discourse/initializers/sign-up-username-override.gjs new file mode 100644 index 0000000..510262a --- /dev/null +++ b/assets/javascripts/discourse/initializers/sign-up-username-override.gjs @@ -0,0 +1,42 @@ +import { on } from "@ember/modifier"; +import InputTip from "discourse/components/input-tip"; +import valueEntered from "discourse/helpers/value-entered"; +import { apiInitializer } from "discourse/lib/api"; +import { i18n } from "discourse-i18n"; +import RandomizerButton from "../components/randomizer-button"; + +export default apiInitializer((api) => { + const siteSettings = api.container.lookup("service:site-settings"); + + api.renderInOutlet( + "invite-username-input", + + ); +}); diff --git a/assets/stylesheets/anon-usernames.scss b/assets/stylesheets/anon-usernames.scss new file mode 100644 index 0000000..0556bc6 --- /dev/null +++ b/assets/stylesheets/anon-usernames.scss @@ -0,0 +1,37 @@ +@use "lib/viewport"; + +// change ordering of username input in invite sign up +.invites-show { + .invite-form form { + .input-group { + &.email-input { + order: 1; + } + + &.name-input.name-required { + order: 2; + } + + &.username-input { + order: 3; + } + + &.password-input { + order: 4; + } + } + + .invitation-cta { + order: 5; + } + } +} + +.input-with-randomizer-btn { + display: flex; + + .btn-primary { + margin-left: 0.5rem; + height: 2.5rem; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 25e9456..acde816 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -6,4 +6,7 @@ en: discourse_anon_usernames: "Discourse Anon Usernames" js: discourse_anon_usernames: - placeholder: placeholder + errors: + full_name_required: + title: "Full name required" + message: "Please enter your full name (first and last) to generate an anonymous username." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 63f1c3e..a222f0f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1 +1,4 @@ en: + discourse_anon_usernames: + errors: + username_invalid: "The chosen username appears to contain your last name. Please choose a different username." \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 8725c1f..0268565 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true DiscourseAnonUsernames::Engine.routes.draw do - get "/examples" => "examples#index" # define routes here end diff --git a/config/settings.yml b/config/settings.yml index ab8eba1..4c377e1 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -2,3 +2,10 @@ discourse_anon_usernames: discourse_anon_usernames_enabled: default: false client: true + only_generated_usernames: + default: false + client: true + random_words_list: + type: list + default: "" + client: true diff --git a/plugin.rb b/plugin.rb index 8dba5ca..fc16390 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # name: discourse-anon-usernames -# about: TODO +# about: Generate anonymous usernames for users and blocks them from using their real names in usernames # meta_topic_id: TODO # version: 0.0.1 # authors: Discourse @@ -16,6 +16,17 @@ module ::DiscourseAnonUsernames require_relative "lib/discourse_anon_usernames/engine" +register_asset "stylesheets/anon-usernames.scss" + after_initialize do - # Code which should run after Rails has finished booting + register_modifier(:username_validation) do |errors, context| + next if context.object.nil? || context.object.name.blank? + + username = context.username + last_name = context.object.name.split(" ").last + + is_leaking_last_name = username.downcase.include?(last_name.downcase) + + errors << I18n.t("discourse_anon_usernames.errors.username_invalid") if is_leaking_last_name + end end diff --git a/spec/requests/users_creation_spec.rb b/spec/requests/users_creation_spec.rb new file mode 100644 index 0000000..ccbe1d9 --- /dev/null +++ b/spec/requests/users_creation_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +RSpec.describe "Users Creation" do + before { SiteSetting.discourse_anon_usernames_enabled = true } + + describe "InvitesController#perform_accept_invitation" do + context "with an email invite" do + let(:topic) { Fabricate(:topic) } + let(:invite) { Invite.generate(topic.user, email: "foo@discourse.org", topic: topic) } + + it "does not allow leaking last names in usernames" do + put "/invites/show/#{invite.invite_key}.json", + params: { + username: "John_smith", + name: "John Smith", + password: "someverystringpassword", + } + + expect(response.status).to eq(412) + expect(response.body).to include(I18n.t("discourse_anon_usernames.errors.username_invalid")) + + expect(User.last.username).not_to eq("John_smith") + end + end + end +end diff --git a/spec/system/invites_flow_spec.rb b/spec/system/invites_flow_spec.rb new file mode 100644 index 0000000..291b780 --- /dev/null +++ b/spec/system/invites_flow_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.describe "User invite flow" do + before do + SiteSetting.discourse_anon_usernames_enabled = true + + SiteSetting.full_name_requirement = "required_at_signup" + SiteSetting.random_words_list = "Apple|Grape|Orange" + end + + let(:topic) { Fabricate(:topic) } + let(:invite) { Invite.generate(topic.user, email: "john@discourse.org", topic: topic) } + let(:invite_form) { PageObjects::Pages::DiscourseAnonUsernames::InviteFormPage.new } + describe "invite acceptance" do + it "does not let user leak last names in usernames" do + invite_form.open(invite.invite_key) + + invite_form.fill_name("John Smith") + invite_form.fill_username("John_smith") + invite_form.fill_password("someverystringpassword") + + invite_form.accept! + + expect(page).to have_content(I18n.t("discourse_anon_usernames.errors.username_invalid")) + end + + describe "with `only_generated_usernames` setting enabled" do + before { SiteSetting.only_generated_usernames = true } + + it "forces users to use generated usernames" do + invite_form.open(invite.invite_key) + + expect(invite_form).to have_username_disabled + expect(invite_form.username_field.value).to eq("") + + # generated usernames follow the pattern: + # + + invite_form.fill_name("John Smith") + invite_form.randomize_username! + + generated_username = invite_form.username_field.value + expect(generated_username).to include("John") + + random_words = SiteSetting.random_words_list.split("|") + contains_random_word = random_words.any? { |word| generated_username.include?(word) } + expect(contains_random_word).to eq(true) + + invite_form.fill_password("someverystringpassword") + invite_form.accept! + + expect(page).to have_css(".login-welcome-header") + end + end + end +end diff --git a/spec/system/page_objects/pages/invite_form.rb b/spec/system/page_objects/pages/invite_form.rb new file mode 100644 index 0000000..3d85ea6 --- /dev/null +++ b/spec/system/page_objects/pages/invite_form.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + module DiscourseAnonUsernames + class InviteFormPage < PageObjects::Pages::InviteForm + def fill_name(full_name) + find("#new-account-name").fill_in(with: full_name) + end + + def accept! + find(".invitation-cta__accept").click + end + + def username_field + find("#new-account-username") + end + + def has_username_disabled? + username_field[:disabled] == true + end + + def randomize_username! + find(".randomizer-btn").click + end + end + end + end +end