This introduces a much simplified signup flow, over email.
This code is live on https://harmony.bugzilla.ninja
## Before
1. Visit createaccount.cgi
2. Type email
3. Get email, click link
4. Create account
5. Re-enter login and password.
## After
1. Type email into any page
2. Get email, click link
3. Create account + login at the same time
use Bugzilla::App::BouncedEmails;
use Bugzilla::App::CGI;
use Bugzilla::App::Main;
+use Bugzilla::App::Users;
use Bugzilla::App::OAuth2::Clients;
use Bugzilla::App::SES;
use Bugzilla::App::Static;
Bugzilla::App::BouncedEmails->setup_routes($r);
Bugzilla::App::CGI->setup_routes($r);
Bugzilla::App::Main->setup_routes($r);
+ Bugzilla::App::Users->setup_routes($r);
Bugzilla::App::OAuth2::Clients->setup_routes($r);
Bugzilla::App::SES->setup_routes($r);
my $name = sprintf '%s.%s.tmpl', $options->{template}, $options->{format};
my $template = Bugzilla->template;
+ if ($options->{variant}) {
+ my $name_variant = sprintf '%s.%s+%s.tmpl', $options->{template}, $options->{format}, $options->{variant};
+ WARN("loading $name_variant");
+ my $rendered = $template->process($name_variant, \%params, $output);
+ return if $rendered;
+
+ my $error = $template->error;
+ die $error unless $error->type eq 'file' && $error->info =~ /not found/;
+ }
+ WARN("loading $name");
$template->process($name, \%params, $output) or die $template->error;
}
);
--- /dev/null
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::App::Users;
+use Mojo::Base 'Mojolicious::Controller';
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Logging;
+use Bugzilla::Mailer qw(MessageToMTA);
+use Date::Format qw(ctime);
+use Scalar::Util qw(blessed);
+use Bugzilla::User;
+use List::Util qw(any);
+use Try::Tiny;
+
+sub setup_routes {
+ my ($class, $r) = @_;
+
+ $r->post('/signup/email')->to('Users#signup_email')->name('signup_email');
+ $r->get('/signup/email/:token/verify')->to('Users#signup_email_verify')
+ ->name('signup_email_verify');
+ $r->post('/signup/email/:token/finish')->to('Users#signup_email_finish')
+ ->name('signup_email_finish');
+}
+
+sub signup_email {
+ my ($c) = @_;
+ my $v = $c->validation;
+
+ try {
+ Bugzilla::User->new->check_account_creation_enabled;
+ my $email_regexp = Bugzilla->params->{createemailregexp};
+ $v->required('email')->like(qr/$email_regexp/);
+ $v->csrf_protect;
+
+ ThrowUserError('account_creation_restricted') unless $v->is_valid;
+
+ my $email = $v->param('email');
+ Bugzilla::User->check_login_name_for_creation($email);
+ Bugzilla::Hook::process("user_verify_login", {login => $email});
+
+ $c->issue_new_user_account_token($email);
+ $c->render(handler => 'bugzilla');
+ }
+ catch {
+ $c->bugzilla->error_page($_);
+ };
+}
+
+sub signup_email_verify {
+ my ($c) = @_;
+ my $token = $c->stash->{token};
+ my (undef, $issuedate, $email) = Bugzilla::Token::GetTokenData($token);
+
+ if ($email) {
+ $c->stash->{signup_token} = $token;
+ $c->stash->{email} = $email;
+ $c->stash->{expires} = $issuedate;
+ }
+ else {
+ $c->stash->{missing_token} = 1;
+ }
+
+ $c->render(handler => 'bugzilla');
+}
+
+sub signup_email_finish {
+ my ($c) = @_;
+ my $v = $c->validation;
+ try {
+ $v->optional('create')->equal_to('create');
+ $v->optional('cancel')->equal_to('cancel');
+ $v->csrf_protect;
+ $v->required('signup_token')->size(22);
+
+ my $token = $v->param('signup_token');
+ if ($v->is_valid) {
+ my (undef, undef, $email) = Bugzilla::Token::GetTokenData($token);
+
+ $v->error('signup_token', ['invalid_token']) unless $email;
+
+ if ($v->is_valid && $v->param('create') eq 'create') {
+ $v->optional('realname')->size(1, 255);
+ $v->required('etiquette');
+ $v->required('password')->size(8, 100);
+ $v->required('password_confirm')->size(8, 100);
+ if ($v->is_valid && $v->param('password') ne $v->param('password_confirm')) {
+ $v->error('password_confirm', ['password_mismatch']);
+ $v->error('password', ['password_mismatch']);
+ }
+ if ($v->is_valid) {
+ my $new_user = Bugzilla::User->create({
+ login_name => $email,
+ realname => $v->param('realname'),
+ cryptpassword => $v->param('password'),
+ });
+ $c->persist_login($new_user, 'signup');
+ $c->redirect_to('/home');
+ }
+ }
+ elsif ($v->is_valid && $v->param('cancel') eq 'cancel') {
+ my (undef, undef, $email) = Bugzilla::Token::GetTokenData($token);
+ my $vars = {};
+ $vars->{'message'} = 'account_creation_canceled';
+ $vars->{'account'} = $email;
+ Bugzilla::Token::Cancel($token, $vars->{'message'});
+ }
+ }
+ ThrowUserError('validation', { v => $v });
+ }
+ catch {
+ $c->bugzilla->error_page($_);
+ };
+}
+
+# This is adapted from issue_new_user_account_token from Bugzilla/Token.pm
+# Creates and sends a token to create a new user account.
+# It assumes that the login has the correct format and is not already in use.
+sub issue_new_user_account_token {
+ my ($c, $email) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ # Is there already a pending request for this login name? If yes, do not throw
+ # an error because the user may have lost their email with the token inside.
+ # But to prevent using this way to mailbomb an email address, make sure
+ # the last request is at least 10 minutes old before sending a new email.
+
+ my $pending_requests = $dbh->selectrow_array(
+ 'SELECT COUNT(*)
+ FROM tokens
+ WHERE tokentype = ?
+ AND ' . $dbh->sql_istrcmp('eventdata', '?') . '
+ AND issuedate > '
+ . $dbh->sql_date_math('NOW()', '-', 10, 'MINUTE'), undef, ('signup', $email)
+ );
+
+ ThrowUserError('too_soon_for_new_token', {'type' => 'signup'})
+ if $pending_requests;
+
+ my ($token, $token_ts)
+ = Bugzilla::Token::_create_token(undef, 'signup', $email);
+
+ $c->stash->{email} = $email . Bugzilla->params->{'emailsuffix'};
+ $c->stash->{expires} = ctime($token_ts + MAX_TOKEN_AGE * 86400);
+ $c->stash->{verify_url}
+ = $c->url_for('signup_email_verify', token => $token)->to_abs;
+
+ my $message = $c->render_to_string(
+ handler => 'bugzilla',
+ format => 'txt',
+ variant => 'email'
+ );
+ WARN("Email: is\n$message");
+ MessageToMTA($message->to_string);
+}
+
+# This is adapted from persist_login in Bugzilla/Auth/Persist/Cookie.pm
+sub persist_login {
+ my ($c, $user, $auth_method) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ $dbh->bz_start_transaction();
+
+ my $login_cookie
+ = Bugzilla::Token::GenerateUniqueToken('logincookies', 'cookie');
+
+ my $ip_addr = $c->forwarded_for;
+
+ $dbh->do(
+ 'INSERT INTO logincookies (cookie, userid, ipaddr, lastused)
+ VALUES (?, ?, ?, NOW())', undef, $login_cookie, $user->id, $ip_addr
+ );
+
+ # Issuing a new cookie is a good time to clean up the old
+ # cookies.
+ $dbh->do("DELETE FROM logincookies WHERE lastused < "
+ . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-', MAX_LOGINCOOKIE_AGE, 'DAY'));
+
+ $dbh->bz_commit_transaction();
+
+ my %cookie_attr = (httponly => 1, path => '/', expires => time + 604800);
+
+ if (Bugzilla->localconfig->urlbase =~ /^https/) {
+ $cookie_attr{secure} = 1;
+ }
+
+ $c->cookie('Bugzilla_login', $user->id, \%cookie_attr);
+ $c->cookie('Bugzilla_logincookie', $login_cookie, \%cookie_attr);
+
+ my $securemail_groups
+ = Bugzilla->can('securemail_groups')
+ ? Bugzilla->securemail_groups
+ : ['admin'];
+
+ if (any { $user->in_group($_) } @$securemail_groups) {
+ $auth_method //= 'unknown';
+
+ Bugzilla->audit(
+ sprintf "successful login of %s from %s using \"%s\", authenticated by %s",
+ $user->login, $ip_addr, $c->req->headers->user_agent // '', $auth_method);
+ }
+
+ return $login_cookie;
+}
+
+
+1;
{name => 'announcehtml', type => 'l', default => ''},
+ {
+ name => 'etiquettehtml',
+ type => 'l',
+ default =>
+ 'I have read <a href="/page.cgi?id=etiquette.html">Bugzilla Etiquette</a> and agree to abide by it.'
+ },
+
{
name => 'upgrade_notification',
type => 's',
+++ /dev/null
-#!/usr/bin/env perl
-# This Source Code Form is subject to the terms of the Mozilla Public
-# License, v. 2.0. If a copy of the MPL was not distributed with this
-# file, You can obtain one at http://mozilla.org/MPL/2.0/.
-#
-# This Source Code Form is "Incompatible With Secondary Licenses", as
-# defined by the Mozilla Public License, v. 2.0.
-
-use 5.10.1;
-use strict;
-use warnings;
-
-use lib qw(. lib local/lib/perl5);
-
-use Bugzilla;
-use Bugzilla::Constants;
-use Bugzilla::Error;
-use Bugzilla::Token;
-
-# Just in case someone already has an account, let them get the correct footer
-# on an error message. The user is logged out just after the account is
-# actually created.
-my $user = Bugzilla->login(LOGIN_OPTIONAL);
-my $cgi = Bugzilla->cgi;
-my $template = Bugzilla->template;
-my $vars = {doc_section => 'myaccount.html'};
-
-print $cgi->header();
-
-$user->check_account_creation_enabled;
-my $login = $cgi->param('login');
-
-if (defined($login)) {
-
- # Check the hash token to make sure this user actually submitted
- # the create account form.
- my $token = $cgi->param('token');
- check_hash_token($token, ['create_account']);
-
- $user->check_and_send_account_creation_confirmation($login);
- $vars->{'login'} = $login;
-
- $template->process("account/created.html.tmpl", $vars)
- || ThrowTemplateError($template->error());
- exit;
-}
-
-# Show the standard "would you like to create an account?" form.
-$template->process("account/create.html.tmpl", $vars)
- || ThrowTemplateError($template->error());
$('.hide_mini_login_form').on("click", function (event) {
return hide_mini_login_form($(this).data('qs-suffix'));
});
+ $('.show_mini_signup_form').on("click", function (event) {
+ return show_mini_signup_form($(this).data('qs-suffix'));
+ });
+ $('.hide_mini_signup_form').on("click", function (event) {
+ return hide_mini_signup_form($(this).data('qs-suffix'));
+ });
$('.show_forgot_form').on("click", function (event) {
return show_forgot_form($(this).data('qs-suffix'));
});
function show_mini_login_form( suffix ) {
hide_forgot_form(suffix);
+ hide_mini_signup_form(suffix);
$('#mini_login' + suffix).removeClass('bz_default_hidden').find('input[required]:first').focus();
- $('#new_account_container' + suffix).addClass('bz_default_hidden');
return false;
}
function hide_mini_login_form( suffix ) {
$('#mini_login' + suffix).addClass('bz_default_hidden');
- $('#new_account_container' + suffix).removeClass('bz_default_hidden');
return false;
}
+function show_mini_signup_form( suffix ) {
+ hide_forgot_form(suffix);
+ hide_mini_login_form(suffix);
+ $('#mini_signup' + suffix).removeClass('bz_default_hidden').find('input[required]:first').focus();
+ return false;
+}
+
+function hide_mini_signup_form( suffix ) {
+ $('#mini_signup' + suffix).addClass('bz_default_hidden');
+ return false;
+}
+
+
function show_forgot_form( suffix ) {
hide_mini_login_form(suffix);
$('#forgot_form' + suffix).removeClass('bz_default_hidden').find('input[required]:first').focus();
--- /dev/null
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This Source Code Form is "Incompatible With Secondary Licenses", as
+ * defined by the Mozilla Public License, v. 2.0. */
+
+document.addEventListener("DOMContentLoaded", () => {
+ const password = document.getElementById("password");
+ const password_confirm = document.getElementById("password_confirm");
+ const on_change = (event) => {
+ if (password.value == password_confirm.value) {
+ console.log(password.value);
+ console.log(password_confirm.value);
+ password.setCustomValidity("");
+ password_confirm.setCustomValidity("");
+ } else {
+ password.setCustomValidity("This password doesn't match");
+ password_confirm.setCustomValidity("This password doesn't match");
+ }
+ };
+ if (password && password_confirm) {
+ password.addEventListener("change", on_change);
+ password_confirm.addEventListener("change", on_change);
+ }
+
+ const cancel = document.getElementById("signup_cancel");
+ if (cancel) {
+ cancel.addEventListener("click", (event) => {
+ const not_required = ['etiquette', 'password', 'password_confirm'];
+ for (const id of not_required) {
+ const field = document.getElementById(id);
+ if (field) {
+ field.required = false;
+ }
+ }
+ });
+ }
+});
--- /dev/null
+.signup, .fresh-signup {
+ max-width: 1023px;
+ min-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+ display: grid;
+ grid-template-columns: 1fr 4fr;
+ grid-gap: 16px;
+}
+
+@media screen and (max-width: 790px) {
+ .signup, .fresh-signup {
+ max-width: 600px;
+ }
+}
+
+.signup label {
+ grid-column: 1 / 2;
+}
+
+.signup input {
+ grid-column: 2 / 3;
+}
+
+
+.signup .notes {
+ text-align: left;
+ grid-column: 2 / 3;
+}
+
+.signup .buttons {
+ text-align: right;
+ grid-column: 2 / 3;
+}
+
+.fresh-signup {
+ grid-template-columns: 4fr 1fr;
+}
+
+.fresh-signup .notes {
+ text-align: left;
+ grid-column: 1 / 2;
+}
+
+.fresh-signup input {
+ grid-column: 1 / 2;
+}
+
+.fresh-signup button {
+ grid-column: 2 / 2;
+}
--- /dev/null
+[%# The contents of this file are subject to the Mozilla Public
+ # License Version 1.1 (the "License"); you may not use this file
+ # except in compliance with the License. You may obtain a copy of
+ # the License at http://www.mozilla.org/MPL/
+ #
+ # Software distributed under the License is distributed on an "AS
+ # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
+ # implied. See the License for the specific language governing
+ # rights and limitations under the License.
+ #
+ # The Original Code is the Bugzilla Bug Tracking System.
+ #
+ # The Initial Developer of the Original Code is Netscape Communications
+ # Corporation. Portions created by Netscape are
+ # Copyright (C) 1998 Netscape Communications Corporation. All
+ # Rights Reserved.
+ #
+ # Contributor(s): Jacob Steenhagen <jake@bugzilla.org>
+ #%]
+
+[% PROCESS global/variables.none.tmpl %]
+
+[%# Use the current script name. If an empty name is returned,
+ # then we are accessing the home page. %]
+
+<li id="mini_signup_container[% qs_suffix %]">
+ <a id="signup_link[% qs_suffix %]" href="#"
+ class='show_mini_signup_form' data-qs-suffix="[% qs_suffix FILTER html %]">Sign Up</a>
+
+ <div id="mini_signup[% qs_suffix FILTER html %]" class="mini-popup mini_signup bz_default_hidden">
+ [% Hook.process('additional_methods') %]
+
+ <form action="[% c.url_for('signup_email') FILTER html %]" method="post"
+ data-qs-suffix="[% qs_suffix FILTER html %]">
+
+ <input id="signup_email[% qs_suffix FILTER html %]"
+ class="bz_signup"
+ name="email"
+ title="Email"
+ placeholder="Email"
+ aria-label="Email"
+ type="email"
+ required
+ >
+ <input type="hidden" name="csrf_token"
+ value="[% c.csrf_token %]">
+ <input type="submit" value="Sign up"
+ class="check_mini_signup_fields"
+ id="signup_[% qs_suffix %]">
+ <a href="#" id="hide_mini_signup[% qs_suffix FILTER html %]" aria-label="Close"
+ class="close-button hide_mini_signup_form" data-qs-suffix="[% qs_suffix FILTER html %]">
+ <span class="icon" aria-hidden="true"></span>
+ </a>
+ </form>
+ </div>
+</li>
_ " <code>class=\"warning\"</code> to make the text red. Anything defined"
_ " in <code>skins/standard/global.css</code> will work.",
+ etiquettehtml =>
+ "When users create accounts, this message will display next to mandatory check box."
+
upgrade_notification =>
"$terms.Bugzilla can inform you when a new release is available."
_ " The notification will appear on the $terms.Bugzilla homepage,"
</div>
[% ELSE %]
<ul id="header-login" class="links">
- [% IF Param('createemailregexp') && user.authorizer.user_can_create_account %]
- <li id="moz_new_account_container_top"><a href="[% basepath FILTER none %]createaccount.cgi">New Account</a></li>
- [% END %]
+ [% PROCESS "account/auth/signup-small.html.tmpl" qs_suffix = "_top" %]
[% IF user.authorizer.can_login %]
[% PROCESS "account/auth/login-small.html.tmpl" qs_suffix = "_top" %]
[% END %]
[% title = "Unknown Tab" %]
<code>[% current_tab_name FILTER html %]</code> is not a legal tab name.
+ [% ELSIF error == "validation" %]
+ <p>Some fields failed validation.</p>
+ <ul>
+ [% FOREACH f IN v.failed %]
+ <li>[% f FILTER html %]: [% v.error(f).join(", ") FILTER html %]</li>
+ [% END %]
+ </ul>
+
[% ELSIF error == "value_inactive" %]
[% title = "Value is Not Active" %]
[% type = BLOCK %][% INCLUDE object_name class = class %][% END %]
--- /dev/null
+[% title = BLOCK %]New user account for '[% email FILTER html %]'[% END %]
+[% PROCESS "global/header.html.tmpl"
+ title = title
+ style_urls = ['skins/standard/admin.css']
+ javascript_urls = ['js/account.js']
+%]
+
+<p>
+ We've sent a confirmation message to [% email FILTER html %]. When you recieve
+ it, you'll be able to finish creating your account.
+</p>
+
+
+[% PROCESS global/footer.html.tmpl %]
--- /dev/null
+[%# INTERFACE:
+ # verify_url: random string used to authenticate the transaction.
+ # expiration_ts: expiration date of the token.
+ # email: email address of the new account.
+ #%]
+
+[% PROCESS global/variables.none.tmpl %]
+
+From: [% Param('mailfrom') %]
+To: [% email %]
+Subject: [% terms.Bugzilla %]: complete account signup
+X-Bugzilla-Type: admin
+
+[%+ terms.Bugzilla %] has received a request to create a user account
+using your email address ([% email %]).
+
+To continue creating an account using this email address, visit the
+following link by [% expiration_ts FILTER time("%B %e, %Y at %H:%M %Z") %]:
+
+[%+ verify_url %]
+
+[% IF Param('createemailregexp') == '.*' && Param('emailsuffix') == '' %]
+PRIVACY NOTICE: [% terms.Bugzilla %] is an open [% terms.bug %] tracking system. Activity on most
+[%+ terms.bugs %], including email addresses, will be visible to the public. We recommend
+using a secondary account or free web email service (such as Gmail, Yahoo,
+Hotmail, or similar) to avoid receiving spam at your primary email address.
+[% END %]
+
+If you do not wish to create an account, or if this request was made in
+error you can do ignore it.
--- /dev/null
+[% PROCESS "global/header.html.tmpl"
+ title = "Backend validation problems"
+ style_urls = ['skins/standard/signup.css']
+%]
+[% v = c.stash.v %]
+
+[% PROCESS global/footer.html.tmpl %]
--- /dev/null
+[% title = BLOCK %]Create a new user account for '[% email FILTER html %]'[% END %]
+[% PROCESS "global/header.html.tmpl"
+ title = title
+ style_urls = ['skins/standard/signup.css']
+ javascript_urls = ['js/signup.js']
+%]
+
+[% IF signup_token %]
+ <form class="signup" method="post" action="[% c.url_for('signup_email_finish') %]">
+ <p class="notes">
+ This account will not be created if this form is not completed by
+ <u>[% expires FILTER time("%B %e, %Y at %H:%M %Z") %]</u>.
+ </p>
+ <input type="hidden" name="csrf_token" value="[% c.csrf_token FILTER html %]">
+ <input type="hidden" name="signup_token" value="[% signup_token FILTER html %]">
+
+ <label for="email">Email Address:</label>
+ <input type="text" id="email" readonly value="[% email FILTER html %]">
+
+ <label for="realname">Display Name</label>
+ <input type="text" id="realname" name="realname" value="" placeholder="Long Name :shortname (pronouns)">
+
+ <label for="password">Type your password</label>
+ <input type="password" id="password" name="password" value="" required>
+
+ <label for="passwd2">Confirm your password</label>
+ <input type="password" id="password_confirm" name="password_confirm" value="" required>
+
+ <div class="buttons">
+ <input type="checkbox" id="etiquette" name='etiquette' value="agreed" required>
+ <label for="etiquette"> [% Param('etiquettehtml') FILTER html_light %]</label>
+ </div>
+
+ <div class="buttons">
+ <button class="secondary" type="submit" id="signup_cancel" name="cancel" value="cancel">Cancel</button>
+ <button type="submit" id="signup_create" name="create" value="create">Create</button>
+ </div>
+ </form>
+[% ELSE %]
+ <form class="fresh-signup" method="post" action="[% c.url_for('signup_email') %]">
+ <p class="notes">
+ It seems we can't verify your email address because the signup token is expired.
+ Fill in the email below and we'll try this again.
+ </p>
+ <input type="hidden" name="csrf_token" value="[% c.csrf_token %]">
+ <input type="text" placeholder="Email Address" id="email">
+ <button type="submit" id="confirm" name="submit" value="create">Sign up</button>
+ </form>
+[% END %]
+
+[% PROCESS global/footer.html.tmpl %]