Finished new user invite flow

This commit is contained in:
Dan Brown 2019-08-18 13:11:30 +01:00
parent e5155a5dcb
commit 42d8548960
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
11 changed files with 225 additions and 20 deletions

View File

@ -0,0 +1,106 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
class UserInviteController extends Controller
{
protected $inviteService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param UserInviteService $inviteService
* @param UserRepo $userRepo
*/
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
{
$this->inviteService = $inviteService;
$this->userRepo = $userRepo;
$this->middleware('guest');
parent::__construct();
}
/**
* Show the page for the user to set the password for their account.
* @param string $token
* @return Factory|View|RedirectResponse
* @throws Exception
*/
public function showSetPassword(string $token)
{
try {
$this->inviteService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
return $this->handleTokenException($exception);
}
return view('auth.invite-set-password', [
'token' => $token,
]);
}
/**
* Sets the password for an invited user and then grants them access.
* @param string $token
* @param Request $request
* @return RedirectResponse|Redirector
* @throws Exception
*/
public function setPassword(string $token, Request $request)
{
$this->validate($request, [
'password' => 'required|min:6'
]);
try {
$userId = $this->inviteService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
return $this->handleTokenException($exception);
}
$user = $this->userRepo->getById($userId);
$user->password = bcrypt($request->get('password'));
$user->email_confirmed = true;
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
return redirect('/');
}
/**
* Check and validate the exception thrown when checking an invite token.
* @param Exception $exception
* @return RedirectResponse|Redirector
* @throws Exception
*/
protected function handleTokenException(Exception $exception)
{
if ($exception instanceof UserTokenNotFoundException) {
return redirect('/');
}
if ($exception instanceof UserTokenExpiredException) {
session()->flash('error', trans('errors.invite_token_expired'));
return redirect('/password/email');
}
throw $exception;
}
}

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserUpdateException;
@ -13,18 +14,21 @@ class UserController extends Controller
protected $user;
protected $userRepo;
protected $inviteService;
protected $imageRepo;
/**
* UserController constructor.
* @param User $user
* @param UserRepo $userRepo
* @param UserInviteService $inviteService
* @param ImageRepo $imageRepo
*/
public function __construct(User $user, UserRepo $userRepo, ImageRepo $imageRepo)
public function __construct(User $user, UserRepo $userRepo, UserInviteService $inviteService, ImageRepo $imageRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
$this->inviteService = $inviteService;
$this->imageRepo = $imageRepo;
parent::__construct();
}
@ -75,8 +79,10 @@ class UserController extends Controller
];
$authMethod = config('auth.method');
if ($authMethod === 'standard') {
$validationRules['password'] = 'required|min:5';
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = 'required|min:6';
$validationRules['password-confirm'] = 'required|same:password';
} elseif ($authMethod === 'ldap') {
$validationRules['external_auth_id'] = 'required';
@ -86,13 +92,17 @@ class UserController extends Controller
$user = $this->user->fill($request->all());
if ($authMethod === 'standard') {
$user->password = bcrypt($request->get('password'));
$user->password = bcrypt($request->get('password', str_random(32)));
} elseif ($authMethod === 'ldap') {
$user->external_auth_id = $request->get('external_auth_id');
}
$user->save();
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
if ($request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
@ -139,7 +149,7 @@ class UserController extends Controller
$this->validate($request, [
'name' => 'min:2',
'email' => 'min:2|email|unique:users,email,' . $id,
'password' => 'min:5|required_with:password_confirm',
'password' => 'min:6|required_with:password_confirm',
'password-confirm' => 'same:password|required_with:password',
'setting' => 'array',
'profile_image' => $this->imageRepo->getImageValidationRules(),

View File

@ -28,6 +28,7 @@ import bookSort from "./book-sort";
import settingAppColorPicker from "./setting-app-color-picker";
import entityPermissionsEditor from "./entity-permissions-editor";
import templateManager from "./template-manager";
import newUserPassword from "./new-user-password";
const componentMapping = {
'dropdown': dropdown,
@ -60,6 +61,7 @@ const componentMapping = {
'setting-app-color-picker': settingAppColorPicker,
'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager,
'new-user-password': newUserPassword,
};
window.components = {};

View File

@ -0,0 +1,28 @@
class NewUserPassword {
constructor(elem) {
this.elem = elem;
this.inviteOption = elem.querySelector('input[name=send_invite]');
if (this.inviteOption) {
this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this));
this.inviteOptionChange();
}
}
inviteOptionChange() {
const inviting = (this.inviteOption.value === 'true');
const passwordBoxes = this.elem.querySelectorAll('input[type=password]');
for (const input of passwordBoxes) {
input.disabled = inviting;
}
const container = this.elem.querySelector('#password-input-container');
if (container) {
container.style.display = inviting ? 'none' : 'block';
}
}
}
export default NewUserPassword;

View File

@ -11,6 +11,11 @@ class ToggleSwitch {
stateChange() {
this.input.value = (this.checkbox.checked ? 'true' : 'false');
// Dispatch change event from hidden input so they can be listened to
// like a normal checkbox.
const changeEvent = new Event('change');
this.input.dispatchEvent(changeEvent);
}
}

View File

@ -67,7 +67,11 @@ return [
// User Invite
'user_invite_email_subject' => 'You have been invited to join :appName!',
'user_invite_email_greeting' => 'A user account has been created for you on :appName.',
'user_invite_email_greeting' => 'An account has been created for you on :appName.',
'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
'user_invite_email_action' => 'Set Account Password',
'user_invite_page_welcome' => 'Welcome to :appName!',
'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
'user_invite_page_confirm_button' => 'Confirm Password',
'user_invite_success' => 'Password set, you now have access to :appName!'
];

View File

@ -27,7 +27,7 @@ return [
'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.',
'social_driver_not_found' => 'Social driver not found',
'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
'invite_token_expired' => 'This invitation link has expired. You can try to reset your account password or request a new invite from an administrator.',
'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
// System
'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',

View File

@ -109,7 +109,9 @@ return [
'users_role' => 'User Roles',
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
'users_password' => 'User Password',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 5 characters long.',
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
'users_send_invite_option' => 'Send user invite email',
'users_external_auth_id' => 'External Authentication ID',
'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your LDAP system.',
'users_password_warning' => 'Only fill the below if you would like to change your password.',

View File

@ -0,0 +1,27 @@
@extends('simple-layout')
@section('content')
<div class="container very-small mt-xl">
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('auth.user_invite_page_welcome', ['appName' => setting('app-name')]) }}</h1>
<p>{{ trans('auth.user_invite_page_text', ['appName' => setting('app-name')]) }}</p>
<form action="{{ url('/register/invite/' . $token) }}" method="POST" class="stretch-inputs">
{!! csrf_field() !!}
<div class="form-group">
<label for="password">{{ trans('auth.password') }}</label>
@include('form.password', ['name' => 'password', 'placeholder' => trans('auth.password_hint')])
</div>
<div class="text-right">
<button class="button primary">{{ trans('auth.user_invite_page_confirm_button') }}</button>
</div>
</form>
</div>
</div>
@stop

View File

@ -48,8 +48,23 @@
@endif
@if($authMethod === 'standard')
<div>
<div new-user-password>
<label class="setting-list-label">{{ trans('settings.users_password') }}</label>
@if(!isset($model))
<p class="small">
{{ trans('settings.users_send_invite_text') }}
</p>
@include('components.toggle-switch', [
'name' => 'send_invite',
'value' => old('send_invite', 'true') === 'true',
'label' => trans('settings.users_send_invite_option')
])
@endif
<div id="password-input-container" @if(!isset($model)) style="display: none;" @endif>
<p class="small">{{ trans('settings.users_password_desc') }}</p>
@if(isset($model))
<p class="small">
@ -67,4 +82,6 @@
</div>
</div>
</div>
</div>
@endif

View File

@ -217,6 +217,10 @@ Route::post('/register/confirm/resend', 'Auth\ConfirmEmailController@resend');
Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm');
Route::post('/register', 'Auth\RegisterController@postRegister');
// User invitation routes
Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword');
Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword');
// Password reset link request routes...
Route::get('/password/email', 'Auth\ForgotPasswordController@showLinkRequestForm');
Route::post('/password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail');