DEV: Convert account activation pages to use Ember (#28206)

This commit is contained in:
Jan Cernik 2024-08-12 16:02:00 -05:00 committed by GitHub
parent df18bcd029
commit 5b78bbd138
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 313 additions and 106 deletions

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
export default class ActivateAccountRoute extends DiscourseRoute {
titleToken() {
return I18n.t("login.activate_account");
}
}

View File

@ -110,6 +110,7 @@ export default function () {
this.route("resent"); this.route("resent");
this.route("edit-email"); this.route("edit-email");
}); });
this.route("activate-account", { path: "/u/activate-account/:token" });
this.route("confirm-new-email", { path: "/u/confirm-new-email/:token" }); this.route("confirm-new-email", { path: "/u/confirm-new-email/:token" });
this.route("confirm-old-email", { path: "/u/confirm-old-email/:token" }); this.route("confirm-old-email", { path: "/u/confirm-old-email/:token" });
this.route( this.route(

View File

@ -0,0 +1,128 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import RouteTemplate from "ember-route-template";
import DButton from "discourse/components/d-button";
import hideApplicationHeaderButtons from "discourse/helpers/hide-application-header-buttons";
import hideApplicationSidebar from "discourse/helpers/hide-application-sidebar";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { wavingHandURL } from "discourse/lib/waving-hand-url";
import i18n from "discourse-common/helpers/i18n";
export default RouteTemplate(
class extends Component {
@service siteSettings;
@tracked accountActivated = false;
@tracked isLoading = false;
@tracked needsApproval = false;
@tracked errorMessage = null;
@action
async activate() {
this.isLoading = true;
let hp;
try {
const response = await fetch("/session/hp", {
headers: {
Accept: "application/json",
},
});
hp = await response.json();
} catch (error) {
this.isLoading = false;
popupAjaxError(error);
return;
}
try {
const response = await ajax(
`/u/activate-account/${this.args.model.token}.json`,
{
type: "PUT",
data: {
password_confirmation: hp.value,
challenge: hp.challenge.split("").reverse().join(""),
},
}
);
if (!response.success) {
this.errorMessage = i18n("user.activate_account.already_done");
return;
}
this.accountActivated = true;
if (response.redirect_to) {
window.location.href = response.redirect_to;
} else if (response.needs_approval) {
this.needsApproval = true;
} else {
setTimeout(() => (window.location.href = "/"), 2000);
}
} catch (error) {
this.errorMessage = i18n("user.activate_account.already_done");
}
}
<template>
{{hideApplicationSidebar}}
{{hideApplicationHeaderButtons "search" "login" "signup"}}
<div id="simple-container">
{{#if this.errorMessage}}
<div class="alert alert-error">
{{this.errorMessage}}
</div>
{{else}}
<div class="activate-account">
<h1 class="activate-title">{{i18n
"user.activate_account.welcome_to"
site_name=this.siteSettings.title
}}
<img src={{(wavingHandURL)}} alt="" class="waving-hand" />
</h1>
<br />
{{#if this.accountActivated}}
<div class="perform-activation">
<div class="image">
<img
src="/images/wizard/tada.svg"
class="waving-hand"
alt="tada emoji"
/>
</div>
{{#if this.needsApproval}}
<p>{{i18n "user.activate_account.approval_required"}}</p>
{{else}}
<p>{{i18n "user.activate_account.please_continue"}}</p>
<p>
<DButton
class="continue-button"
@translatedLabel={{i18n
"user.activate_account.continue_button"
site_name=this.siteSettings.title
}}
@href="/"
/>
</p>
{{/if}}
</div>
{{else}}
<DButton
id="activate-account-button"
class="btn-primary"
@action={{this.activate}}
@label="user.activate_account.action"
@disabled={{this.isLoading}}
/>
{{/if}}
</div>
{{/if}}
</div>
</template>
}
);

View File

@ -1,27 +0,0 @@
(function () {
const activateButton = document.querySelector("#activate-account-button");
activateButton.addEventListener("click", async function () {
activateButton.setAttribute("disabled", true);
const hpPath = document.getElementById("data-activate-account").dataset
.path;
try {
const response = await fetch(hpPath, {
headers: {
Accept: "application/json",
},
});
const hp = await response.json();
document.querySelector("#password_confirmation").value = hp.value;
document.querySelector("#challenge").value = hp.challenge
.split("")
.reverse()
.join("");
document.querySelector("#activate-account-form").submit();
} catch (e) {
activateButton.removeAttribute("disabled");
throw e;
}
});
})();

View File

@ -1,6 +0,0 @@
(function () {
const path = document.getElementById("data-auto-redirect").dataset.path;
setTimeout(function () {
window.location.href = path;
}, 2000);
})();

View File

@ -1100,7 +1100,11 @@ class UsersController < ApplicationController
def activate_account def activate_account
expires_now expires_now
render layout: "no_ember"
respond_to do |format|
format.html { render "default/empty" }
format.json { render json: success_json }
end
end end
def perform_account_activation def perform_account_activation
@ -1130,21 +1134,22 @@ class UsersController < ApplicationController
end end
if Wizard.user_requires_completion?(@user) if Wizard.user_requires_completion?(@user)
return redirect_to(wizard_path) @redirect_to = wizard_path
elsif destination_url.present? elsif destination_url.present?
return redirect_to(destination_url, allow_other_host: true) @redirect_to = destination_url
elsif SiteSetting.enable_discourse_connect_provider && elsif SiteSetting.enable_discourse_connect_provider &&
payload = cookies.delete(:sso_payload) payload = cookies.delete(:sso_payload)
return redirect_to(session_sso_provider_url + "?" + payload) @redirect_to = session_sso_provider_url + "?" + payload
end end
else else
@needs_approval = true @needs_approval = true
end end
else else
flash.now[:error] = I18n.t("activation.already_done") return render_json_error(I18n.t("activation.already_done"))
end end
render layout: "no_ember" render json:
success_json.merge(redirect_to: @redirect_to, needs_approval: @needs_approval || false)
end end
def update_activation_email def update_activation_email

View File

@ -1,19 +0,0 @@
<div id='simple-container'>
<div class='activate-account'>
<h1 class="activate-title"><%= t 'activation.welcome_to', site_name: SiteSetting.title %> <img src="<%= Emoji.url_for("wave") %>" alt="" class="waving-hand"></h1>
<br/>
<button class='btn btn-primary' id='activate-account-button'><%= t 'activation.action' %></button>
<%= form_tag(perform_activate_account_path, method: :put, id: 'activate-account-form') do %>
<%= hidden_field_tag 'password_confirmation' %>
<%= hidden_field_tag 'challenge' %>
<% end %>
</div>
</div>
<%- content_for(:no_ember_head) do %>
<%= render_google_universal_analytics_code %>
<%= tag.meta id: 'data-activate-account', data: { path: path('/session/hp') } %>
<%- end %>
<%= preload_script "activate-account" %>

View File

@ -1,27 +0,0 @@
<div id='simple-container'>
<%if flash[:error]%>
<div class='alert alert-error'>
<%=flash[:error]%>
</div>
<%else%>
<div class='activate-account'>
<h1 class="activate-title"><%= t 'activation.welcome_to', site_name: SiteSetting.title %> <img src="<%= Emoji.url_for("wave") %>" alt="" class="waving-hand"></h1>
<br>
<div class='perform-activation'>
<div class="image">
<img src="<%= Discourse.base_path + '/images/wizard/tada.svg' %>" alt="tada emoji" class="waving-hand">
</div>
<% if @needs_approval %>
<p><%= t 'activation.approval_required' %></p>
<% else %>
<p><%= t('activation.please_continue') %></p>
<p><a class="btn" href="<%= path "/" %>"><%= t('activation.continue_button', site_name: SiteSetting.title) -%></a></p>
<%- content_for(:no_ember_head) do %>
<%= tag.meta id: 'data-auto-redirect', data: { path: path('/') } %>
<%- end %>
<%= preload_script 'auto-redirect' %>
<% end %>
</div>
</div>
<%end%>
</div>

View File

@ -1745,6 +1745,14 @@ en:
account_specific: "Your %{provider} account '%{account_description}' will be used for authentication." account_specific: "Your %{provider} account '%{account_description}' will be used for authentication."
generic: "Your %{provider} account will be used for authentication." generic: "Your %{provider} account will be used for authentication."
activate_account:
action: "Click here to activate your account"
already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?"
please_continue: "Your new account is confirmed; you will be redirected to the home page."
continue_button: "Continue to %{site_name}"
welcome_to: "Welcome to %{site_name}!"
approval_required: "A moderator must manually approve your new account before you can access this forum. You'll get an email when your account is approved!"
name: name:
title: "Name" title: "Name"
instructions: "Your full name (optional)." instructions: "Your full name (optional)."
@ -2387,6 +2395,7 @@ en:
resend_activation_email: "Click here to send the activation email again." resend_activation_email: "Click here to send the activation email again."
omniauth_disallow_totp: "Your account has two-factor authentication enabled. Please log in with your password." omniauth_disallow_totp: "Your account has two-factor authentication enabled. Please log in with your password."
activate_account: "Activate Account"
resend_title: "Resend Activation Email" resend_title: "Resend Activation Email"
change_email: "Change Email Address" change_email: "Change Email Address"
provide_new_email: "Provide a new address and we'll resend your confirmation email." provide_new_email: "Provide a new address and we'll resend your confirmation email."

View File

@ -1046,11 +1046,7 @@ en:
connected: "(connected)" connected: "(connected)"
activation: activation:
action: "Click here to activate your account"
already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?" already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?"
please_continue: "Your new account is confirmed; you will be redirected to the home page."
continue_button: "Continue to %{site_name}"
welcome_to: "Welcome to %{site_name}!"
approval_required: "A moderator must manually approve your new account before you can access this forum. You'll get an email when your account is approved!" approval_required: "A moderator must manually approve your new account before you can access this forum. You'll get an email when your account is approved!"
missing_session: "We cannot detect if your account was created, please ensure you have cookies enabled." missing_session: "We cannot detect if your account was created, please ensure you have cookies enabled."
activated: "Sorry, this account has already been activated." activated: "Sorry, this account has already been activated."

View File

@ -60,9 +60,8 @@ RSpec.describe UsersController do
context "with invalid token" do context "with invalid token" do
it "return success" do it "return success" do
put "/u/activate-account/invalid-tooken" put "/u/activate-account/invalid-token"
expect(response.status).to eq(200) expect(response.status).to eq(422)
expect(flash[:error]).to be_present
end end
end end
@ -108,12 +107,11 @@ RSpec.describe UsersController do
) )
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(flash[:error]).to be_blank
expect(session[:current_user_id]).to be_present
expect(CGI.unescapeHTML(response.body)).to_not include( data = JSON.parse(response.body)
I18n.t("activation.approval_required"), expect(data["needs_approval"]).to eq(false)
)
expect(session[:current_user_id]).to be_present
end end
end end
@ -124,11 +122,9 @@ RSpec.describe UsersController do
put "/u/activate-account/#{email_token.token}" put "/u/activate-account/#{email_token.token}"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(I18n.t("activation.approval_required")) data = JSON.parse(response.body)
expect(data["needs_approval"]).to eq(true)
expect(response.body).to_not have_tag(:script, with: { src: "/assets/application.js" })
expect(flash[:error]).to be_blank
expect(session[:current_user_id]).to be_blank expect(session[:current_user_id]).to be_blank
end end
end end
@ -141,7 +137,8 @@ RSpec.describe UsersController do
put "/u/activate-account/#{email_token.token}" put "/u/activate-account/#{email_token.token}"
expect(response).to redirect_to(destination_url) expect(response.status).to eq(200)
expect(response.parsed_body["redirect_to"]).to eq(destination_url)
end end
end end
@ -158,7 +155,8 @@ RSpec.describe UsersController do
it "should redirect to the topic" do it "should redirect to the topic" do
put "/u/activate-account/#{email_token.token}" put "/u/activate-account/#{email_token.token}"
expect(response).to redirect_to(topic.relative_url) expect(response.status).to eq(200)
expect(response.parsed_body["redirect_to"]).to eq(topic.relative_url)
end end
end end
end end

View File

@ -4,8 +4,10 @@ require "rotp"
shared_examples "login scenarios" do shared_examples "login scenarios" do
let(:login_modal) { PageObjects::Modals::Login.new } let(:login_modal) { PageObjects::Modals::Login.new }
let(:activate_account) { PageObjects::Pages::ActivateAccount.new }
let(:user_preferences_security_page) { PageObjects::Pages::UserPreferencesSecurity.new } let(:user_preferences_security_page) { PageObjects::Pages::UserPreferencesSecurity.new }
fab!(:user) { Fabricate(:user, username: "john", password: "supersecurepassword") } fab!(:user) { Fabricate(:user, username: "john", password: "supersecurepassword") }
fab!(:admin) { Fabricate(:admin, username: "admin", password: "supersecurepassword") }
let(:user_menu) { PageObjects::Components::UserMenu.new } let(:user_menu) { PageObjects::Components::UserMenu.new }
before { Jobs.run_immediately! } before { Jobs.run_immediately! }
@ -43,12 +45,39 @@ shared_examples "login scenarios" do
activation_link = wait_for_email_link(user, :activation) activation_link = wait_for_email_link(user, :activation)
visit activation_link visit activation_link
find("#activate-account-button").click activate_account.click_activate_account
activate_account.click_continue
expect(page).to have_current_path("/") expect(page).to have_current_path("/")
expect(page).to have_css(".header-dropdown-toggle.current-user") expect(page).to have_css(".header-dropdown-toggle.current-user")
end end
it "redirects to the wizard after activating account" do
login_modal.open
login_modal.fill(username: "admin", password: "supersecurepassword")
login_modal.click_login
expect(page).to have_css(".not-activated-modal")
login_modal.click(".activation-controls button.resend")
activation_link = wait_for_email_link(admin, :activation)
visit activation_link
activate_account.click_activate_account
expect(page).to have_current_path("/wizard")
end
it "shows error when when activation link is invalid" do
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
expect(page).to have_css(".not-activated-modal")
visit "/u/activate-account/invalid"
activate_account.click_activate_account
expect(activate_account).to have_error
end
it "displays the right message when user's email has been marked as expired" do it "displays the right message when user's email has been marked as expired" do
password = "myawesomepassword" password = "myawesomepassword"
user.update!(password:) user.update!(password:)

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
module PageObjects
module Pages
class ActivateAccount < PageObjects::Pages::Base
def click_activate_account
find("#activate-account-button").click
end
def click_continue
find(".perform-activation .continue-button").click
end
def has_error?
has_css?("#simple-container .alert-error")
has_content?(I18n.t("js.user.activate_account.already_done"))
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
module PageObjects
module Pages
class InviteForm < PageObjects::Pages::Base
def open(key)
visit "/invites/#{key}"
end
def fill_username(username)
find("#new-account-username").fill_in(with: username)
end
def fill_password(password)
find("#new-account-password").fill_in(with: password)
end
def has_valid_username?
find(".username-input").has_css?("#username-validation.good")
end
def has_valid_password?
find(".password-input").has_css?("#password-validation.good")
end
def has_valid_fields?
has_valid_username?
has_valid_password?
end
def click_create_account
find(".invitation-cta__accept.btn-primary").click
end
def has_successful_message?
has_css?(".invite-success")
end
end
end
end

View File

@ -3,11 +3,15 @@
shared_examples "signup scenarios" do shared_examples "signup scenarios" do
let(:login_modal) { PageObjects::Modals::Login.new } let(:login_modal) { PageObjects::Modals::Login.new }
let(:signup_modal) { PageObjects::Modals::Signup.new } let(:signup_modal) { PageObjects::Modals::Signup.new }
let(:invite_form) { PageObjects::Pages::InviteForm.new }
let(:activate_account) { PageObjects::Pages::ActivateAccount.new }
let(:invite) { Fabricate(:invite, email: "johndoe@example.com") }
let(:topic) { Fabricate(:topic, title: "Super cool topic") }
context "when anyone can create an account" do context "when anyone can create an account" do
it "can signup" do before { Jobs.run_immediately! }
Jobs.run_immediately!
it "can signup" do
signup_modal.open signup_modal.open
signup_modal.fill_email("johndoe@example.com") signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john") signup_modal.fill_username("john")
@ -18,6 +22,53 @@ shared_examples "signup scenarios" do
expect(page).to have_css(".account-created") expect(page).to have_css(".account-created")
end end
it "can signup and activate account" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_modal.click_create_account
expect(page).to have_css(".account-created")
mail = ActionMailer::Base.deliveries.first
expect(mail.to).to contain_exactly("johndoe@example.com")
activation_link = mail.body.to_s[%r{/u/activate-account/\S+}]
visit activation_link
activate_account.click_activate_account
activate_account.click_continue
expect(page).to have_current_path("/")
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "redirects to the topic the user was invited to after activating account" do
TopicInvite.create!(invite: invite, topic: topic)
invite_form.open(invite.invite_key)
invite_form.fill_username("john")
invite_form.fill_password("supersecurepassword")
expect(invite_form).to have_valid_fields
invite_form.click_create_account
expect(invite_form).to have_successful_message
mail = ActionMailer::Base.deliveries.first
expect(mail.to).to contain_exactly("johndoe@example.com")
activation_link = mail.body.to_s[%r{/u/activate-account/\S+}]
visit activation_link
activate_account.click_activate_account
expect(page).to have_current_path("/t/#{topic.slug}/#{topic.id}")
end
context "with invite code" do context "with invite code" do
before { SiteSetting.invite_code = "cupcake" } before { SiteSetting.invite_code = "cupcake" }
@ -124,6 +175,7 @@ shared_examples "signup scenarios" do
user = User.find_by(username: "john") user = User.find_by(username: "john")
EmailToken.confirm(Fabricate(:email_token, user: user).token) EmailToken.confirm(Fabricate(:email_token, user: user).token)
visit "/"
login_modal.open login_modal.open
login_modal.fill_username("john") login_modal.fill_username("john")
login_modal.fill_password("supersecurepassword") login_modal.fill_password("supersecurepassword")