diff --git a/src/applications/auth/constants/PhabricatorCookies.php b/src/applications/auth/constants/PhabricatorCookies.php --- a/src/applications/auth/constants/PhabricatorCookies.php +++ b/src/applications/auth/constants/PhabricatorCookies.php @@ -57,6 +57,12 @@ const COOKIE_HISEC = 'jump_to_hisec'; + /** + * Stores an invite code. + */ + const COOKIE_INVITE = 'invite'; + + /* -( Client ID Cookie )--------------------------------------------------- */ diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php --- a/src/applications/auth/controller/PhabricatorAuthController.php +++ b/src/applications/auth/controller/PhabricatorAuthController.php @@ -108,6 +108,9 @@ // Clear the client ID / OAuth state key. $request->clearCookie(PhabricatorCookies::COOKIE_CLIENTID); + + // Clear the invite cookie. + $request->clearCookie(PhabricatorCookies::COOKIE_INVITE); } private function buildLoginValidateResponse(PhabricatorUser $user) { @@ -246,4 +249,57 @@ return array($account, $provider, null); } + protected function loadInvite() { + $invite_cookie = PhabricatorCookies::COOKIE_INVITE; + $invite_code = $this->getRequest()->getCookie($invite_cookie); + if (!$invite_code) { + return null; + } + + $engine = id(new PhabricatorAuthInviteEngine()) + ->setViewer($this->getViewer()) + ->setUserHasConfirmedVerify(true); + + try { + return $engine->processInviteCode($invite_code); + } catch (Exception $ex) { + // If this fails for any reason, just drop the invite. In normal + // circumstances, we gave them a detailed explanation of any error + // before they jumped into this workflow. + return null; + } + } + + protected function renderInviteHeader(PhabricatorAuthInvite $invite) { + $viewer = $this->getViewer(); + + $invite_author = id(new PhabricatorPeopleQuery()) + ->setViewer($viewer) + ->withPHIDs(array($invite->getAuthorPHID())) + ->needProfileImage(true) + ->executeOne(); + + // If we can't load the author for some reason, just drop this message. + // We lose the value of contextualizing things without author details. + if (!$invite_author) { + return null; + } + + $invite_item = id(new PHUIObjectItemView()) + ->setHeader(pht('Welcome to Phabricator!')) + ->setImageURI($invite_author->getProfileImageURI()) + ->addAttribute( + pht( + '%s has invited you to join Phabricator.', + $invite_author->getFullName())); + + $invite_list = id(new PHUIObjectItemListView()) + ->addItem($invite_item) + ->setFlush(true); + + return id(new PHUIBoxView()) + ->addMargin(PHUI::MARGIN_LARGE) + ->appendChild($invite_list); + } + } diff --git a/src/applications/auth/controller/PhabricatorAuthInviteController.php b/src/applications/auth/controller/PhabricatorAuthInviteController.php --- a/src/applications/auth/controller/PhabricatorAuthInviteController.php +++ b/src/applications/auth/controller/PhabricatorAuthInviteController.php @@ -17,8 +17,10 @@ $engine->setUserHasConfirmedVerify(true); } + $invite_code = $request->getURIData('code'); + try { - $invite = $engine->processInviteCode($request->getURIData('code')); + $invite = $engine->processInviteCode($invite_code); } catch (PhabricatorAuthInviteDialogException $ex) { $response = $this->newDialog() ->setTitle($ex->getTitle()) @@ -48,10 +50,13 @@ return id(new AphrontRedirectResponse())->setURI('/'); } + // Give the user a cookie with the invite code and send them through + // normal registration. We'll adjust the flow there. + $request->setCookie( + PhabricatorCookies::COOKIE_INVITE, + $invite_code); - // TODO: This invite is good, but we need to drive the user through - // registration. - throw new Exception(pht('TODO: Build invite/registration workflow.')); + return id(new AphrontRedirectResponse())->setURI('/auth/start/'); } diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php --- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -38,19 +38,25 @@ return $response; } - if (!$provider->shouldAllowRegistration()) { + $invite = $this->loadInvite(); - // TODO: This is a routine error if you click "Login" on an external - // auth source which doesn't allow registration. The error should be - // more tailored. + if (!$provider->shouldAllowRegistration()) { + if ($invite) { + // If the user has an invite, we allow them to register with any + // provider, even a login-only provider. + } else { + // TODO: This is a routine error if you click "Login" on an external + // auth source which doesn't allow registration. The error should be + // more tailored. - return $this->renderError( - pht( - 'The account you are attempting to register with uses an '. - 'authentication provider ("%s") which does not allow registration. '. - 'An administrator may have recently disabled registration with this '. - 'provider.', - $provider->getProviderName())); + return $this->renderError( + pht( + 'The account you are attempting to register with uses an '. + 'authentication provider ("%s") which does not allow '. + 'registration. An administrator may have recently disabled '. + 'registration with this provider.', + $provider->getProviderName())); + } } $user = new PhabricatorUser(); @@ -59,9 +65,15 @@ $default_realname = $account->getRealName(); $default_email = $account->getEmail(); + + if ($invite) { + $default_email = $invite->getEmailAddress(); + } + if (!PhabricatorUserEmail::isValidAddress($default_email)) { $default_email = null; } + if ($default_email !== null) { // We should bypass policy here becase e.g. limiting an application use // to a subset of users should not allow the others to overwrite @@ -105,7 +117,13 @@ 'address = %s', $default_email); if ($same_email) { - $default_email = null; + if ($invite) { + // We're allowing this to continue. The fact that we loaded the + // invite means that the address is nonprimary and unverified and + // we're OK to steal it. + } else { + $default_email = null; + } } } } @@ -166,7 +184,13 @@ $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; - if ($request->isFormPost() || !$can_edit_anything) { + $from_invite = $request->getStr('invite'); + if ($from_invite && $can_edit_username) { + $value_username = $request->getStr('username'); + $e_username = null; + } + + if (($request->isFormPost() || !$can_edit_anything) && !$from_invite) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($must_set_password) { @@ -252,28 +276,48 @@ } try { + $verify_email = false; + if ($force_verify) { $verify_email = true; - } else { - $verify_email = - ($account->getEmailVerified()) && - ($value_email === $default_email); } - if ($provider->shouldTrustEmails() && - $value_email === $default_email) { - $verify_email = true; + if ($value_email === $default_email) { + if ($account->getEmailVerified()) { + $verify_email = true; + } + + if ($provider->shouldTrustEmails()) { + $verify_email = true; + } + + if ($invite) { + $verify_email = true; + } } - $email_obj = id(new PhabricatorUserEmail()) - ->setAddress($value_email) - ->setIsVerified((int)$verify_email); + $email_obj = null; + if ($invite) { + // If we have a valid invite, this email may exist but be + // nonprimary and unverified, so we'll reassign it. + $email_obj = id(new PhabricatorUserEmail())->loadOneWhere( + 'address = %s', + $value_email); + } + if (!$email_obj) { + $email_obj = id(new PhabricatorUserEmail()) + ->setAddress($value_email); + } + + $email_obj->setIsVerified((int)$verify_email); $user->setUsername($value_username); $user->setRealname($value_realname); if ($is_setup) { $must_approve = false; + } else if ($invite) { + $must_approve = false; } else { $must_approve = PhabricatorEnv::getEnvConfig( 'auth.require-approval'); @@ -285,12 +329,18 @@ $user->setIsApproved(1); } + if ($invite) { + $allow_reassign_email = true; + } else { + $allow_reassign_email = false; + } + $user->openTransaction(); $editor = id(new PhabricatorUserEditor()) ->setActor($user); - $editor->createNewUser($user, $email_obj); + $editor->createNewUser($user, $email_obj, $allow_reassign_email); if ($must_set_password) { $envelope = new PhutilOpaqueEnvelope($value_password); $editor->changePassword($user, $envelope); @@ -314,6 +364,10 @@ $this->sendWaitingForApprovalEmail($user); } + if ($invite) { + $invite->setAcceptedByPHID($user->getPHID())->save(); + } + return $this->loginUser($user); } catch (AphrontDuplicateKeyQueryException $exception) { $same_username = id(new PhabricatorUser())->loadOneWhere( @@ -374,21 +428,30 @@ ->setError($e_username)); } + if ($can_edit_realname) { + $form->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Real Name')) + ->setName('realName') + ->setValue($value_realname) + ->setError($e_realname)); + } + if ($must_set_password) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Password')) ->setName('password') + ->setError($e_password)); + $form->appendChild( + id(new AphrontFormPasswordControl()) + ->setLabel(pht('Confirm Password')) + ->setName('confirm') ->setError($e_password) ->setCaption( $min_len ? pht('Minimum length of %d characters.', $min_len) : null)); - $form->appendChild( - id(new AphrontFormPasswordControl()) - ->setLabel(pht('Confirm Password')) - ->setName('confirm') - ->setError($e_password)); } if ($can_edit_email) { @@ -401,15 +464,6 @@ ->setError($e_email)); } - if ($can_edit_realname) { - $form->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('Real Name')) - ->setName('realName') - ->setValue($value_realname) - ->setError($e_realname)); - } - if ($must_set_password) { $form->appendChild( id(new AphrontFormRecaptchaControl()) @@ -459,10 +513,16 @@ ->setForm($form) ->setFormErrors($errors); + $invite_header = null; + if ($invite) { + $invite_header = $this->renderInviteHeader($invite); + } + return $this->buildApplicationPage( array( $crumbs, $welcome_view, + $invite_header, $object_box, ), array( diff --git a/src/applications/auth/controller/PhabricatorAuthStartController.php b/src/applications/auth/controller/PhabricatorAuthStartController.php --- a/src/applications/auth/controller/PhabricatorAuthStartController.php +++ b/src/applications/auth/controller/PhabricatorAuthStartController.php @@ -109,14 +109,21 @@ } } + $invite = $this->loadInvite(); + $not_buttons = array(); $are_buttons = array(); $providers = msort($providers, 'getLoginOrder'); foreach ($providers as $provider) { + if ($invite) { + $form = $provider->buildInviteForm($this); + } else { + $form = $provider->buildLoginForm($this); + } if ($provider->isLoginFormAButton()) { - $are_buttons[] = $provider->buildLoginForm($this); + $are_buttons[] = $form; } else { - $not_buttons[] = $provider->buildLoginForm($this); + $not_buttons[] = $form; } } @@ -159,6 +166,11 @@ $login_message = PhabricatorEnv::getEnvConfig('auth.login-message'); $login_message = phutil_safe_html($login_message); + $invite_message = null; + if ($invite) { + $invite_message = $this->renderInviteHeader($invite); + } + $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Login')); $crumbs->setBorder(true); @@ -167,6 +179,7 @@ array( $crumbs, $login_message, + $invite_message, $out, ), array( diff --git a/src/applications/auth/engine/PhabricatorAuthInviteEngine.php b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php --- a/src/applications/auth/engine/PhabricatorAuthInviteEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php @@ -250,7 +250,7 @@ } private function getLogoutURI() { - return '/auth/logout/'; + return '/logout/'; } } diff --git a/src/applications/auth/provider/PhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorAuthProvider.php --- a/src/applications/auth/provider/PhabricatorAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorAuthProvider.php @@ -158,6 +158,10 @@ return $this->renderLoginForm($controller->getRequest(), $mode = 'start'); } + public function buildInviteForm(PhabricatorAuthStartController $controller) { + return $this->renderLoginForm($controller->getRequest(), $mode = 'invite'); + } + abstract public function processLoginRequest( PhabricatorAuthLoginController $controller); @@ -401,6 +405,8 @@ $button_text = pht('Link External Account'); } else if ($mode == 'refresh') { $button_text = pht('Refresh Account Link'); + } else if ($mode == 'invite') { + $button_text = pht('Register Account'); } else if ($this->shouldAllowRegistration()) { $button_text = pht('Login or Register'); } else { diff --git a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php --- a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php @@ -135,6 +135,29 @@ return $this->renderPasswordLoginForm($request); } + public function buildInviteForm( + PhabricatorAuthStartController $controller) { + $request = $controller->getRequest(); + $viewer = $request->getViewer(); + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->addHiddenInput('invite', true) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel(pht('Username')) + ->setName('username')); + + $dialog = id(new AphrontDialogView()) + ->setUser($viewer) + ->setTitle(pht('Register an Account')) + ->appendForm($form) + ->setSubmitURI('/auth/register/') + ->addSubmitButton(pht('Continue')); + + return $dialog; + } + public function buildLinkForm( PhabricatorAuthLinkController $controller) { throw new Exception("Password providers can't be linked."); diff --git a/src/applications/people/editor/PhabricatorUserEditor.php b/src/applications/people/editor/PhabricatorUserEditor.php --- a/src/applications/people/editor/PhabricatorUserEditor.php +++ b/src/applications/people/editor/PhabricatorUserEditor.php @@ -23,14 +23,25 @@ */ public function createNewUser( PhabricatorUser $user, - PhabricatorUserEmail $email) { + PhabricatorUserEmail $email, + $allow_reassign = false) { if ($user->getID()) { throw new Exception('User has already been created!'); } + $is_reassign = false; if ($email->getID()) { - throw new Exception('Email has already been created!'); + if ($allow_reassign) { + if ($email->getIsPrimary()) { + throw new Exception( + pht( + 'Primary email addresses can not be reassigned.')); + } + $is_reassign = true; + } else { + throw new Exception('Email has already been created!'); + } } if (!PhabricatorUser::validateUsername($user->getUsername())) { @@ -71,6 +82,15 @@ $log->setNewValue($email->getAddress()); $log->save(); + if ($is_reassign) { + $log = PhabricatorUserLog::initializeNewLog( + $this->requireActor(), + $user->getPHID(), + PhabricatorUserLog::ACTION_EMAIL_REASSIGN); + $log->setNewValue($email->getAddress()); + $log->save(); + } + $user->saveTransaction(); return $this;