Skip to content
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,5 @@
module ::DiscourseAnonUsernames
class ExamplesController < ::ApplicationController
requires_plugin PLUGIN_NAME

def index
render json: { hello: "world" }
end
end
end
53 changes: 53 additions & 0 deletions assets/javascripts/discourse/components/randomizer-button.gjs
Original file line number Diff line number Diff line change
@@ -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()
);
}

<template>
<DButton
@icon="rotate"
class="btn-primary randomizer-btn"
@action={{this.generate}}
disabled={{if this.canGenerate false true}}
/>
</template>
}
Original file line number Diff line number Diff line change
@@ -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",
<template>
<div class="input-with-randomizer-btn">
<input
{{on "focusin" @scrollInputIntoView}}
{{on "input" @setAccountUsername}}
type="text"
value={{@accountUsername}}
class={{valueEntered @accountUsername}}
id="new-account-username"
name="username"
disabled={{siteSettings.only_generated_usernames}}
maxlength={{@maxUsernameLength}}
autocomplete="off"
/>

<RandomizerButton
@randomizeFrom={{@accountName}}
@onGenerate={{@setAccountUsername}}
/>

</div>
{{#unless @accountUsername}}
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
</label>
{{/unless}}
<InputTip @validation={{@usernameValidation}} id="username-validation" />
</template>
);
});
37 changes: 37 additions & 0 deletions assets/stylesheets/anon-usernames.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 4 additions & 1 deletion config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
3 changes: 3 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
en:
discourse_anon_usernames:
errors:
username_invalid: "The chosen username appears to contain your last name. Please choose a different username."
1 change: 0 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

DiscourseAnonUsernames::Engine.routes.draw do
get "/examples" => "examples#index"
# define routes here
end

Expand Down
7 changes: 7 additions & 0 deletions config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 13 additions & 2 deletions plugin.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
25 changes: 25 additions & 0 deletions spec/requests/users_creation_spec.rb
Original file line number Diff line number Diff line change
@@ -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: "[email protected]", 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
56 changes: 56 additions & 0 deletions spec/system/invites_flow_spec.rb
Original file line number Diff line number Diff line change
@@ -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: "[email protected]", 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:
# <first_name><random_word><number>

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
29 changes: 29 additions & 0 deletions spec/system/page_objects/pages/invite_form.rb
Original file line number Diff line number Diff line change
@@ -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