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",
+
+
+
+
+
+
+
+ {{#unless @accountUsername}}
+
+ {{/unless}}
+
+
+ );
+});
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