From 42d8548960dc6a59b906ad0794746b22cdfde423 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 18 Aug 2019 13:11:30 +0100
Subject: [PATCH] Finished new user invite flow

---
 .../Controllers/Auth/UserInviteController.php | 106 ++++++++++++++++++
 app/Http/Controllers/UserController.php       |  20 +++-
 resources/assets/js/components/index.js       |   2 +
 .../assets/js/components/new-user-password.js |  28 +++++
 .../assets/js/components/toggle-switch.js     |   5 +
 resources/lang/en/auth.php                    |   6 +-
 resources/lang/en/errors.php                  |   2 +-
 resources/lang/en/settings.php                |   4 +-
 .../views/auth/invite-set-password.blade.php  |  27 +++++
 resources/views/users/form.blade.php          |  41 +++++--
 routes/web.php                                |   4 +
 11 files changed, 225 insertions(+), 20 deletions(-)
 create mode 100644 app/Http/Controllers/Auth/UserInviteController.php
 create mode 100644 resources/assets/js/components/new-user-password.js
 create mode 100644 resources/views/auth/invite-set-password.blade.php

diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php
new file mode 100644
index 000000000..5d9373f45
--- /dev/null
+++ b/app/Http/Controllers/Auth/UserInviteController.php
@@ -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;
+    }
+
+}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 570896ab6..c9d2560ba 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -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(),
diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js
index 8c12da9b4..14cf08ae2 100644
--- a/resources/assets/js/components/index.js
+++ b/resources/assets/js/components/index.js
@@ -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 = {};
diff --git a/resources/assets/js/components/new-user-password.js b/resources/assets/js/components/new-user-password.js
new file mode 100644
index 000000000..9c4c21c14
--- /dev/null
+++ b/resources/assets/js/components/new-user-password.js
@@ -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;
\ No newline at end of file
diff --git a/resources/assets/js/components/toggle-switch.js b/resources/assets/js/components/toggle-switch.js
index 3dd1ce85c..b9b96afc5 100644
--- a/resources/assets/js/components/toggle-switch.js
+++ b/resources/assets/js/components/toggle-switch.js
@@ -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);
     }
 
 }
diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php
index 4474fb7ee..37346097f 100644
--- a/resources/lang/en/auth.php
+++ b/resources/lang/en/auth.php
@@ -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!'
 ];
\ No newline at end of file
diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php
index d66dcc92d..c3b47744d 100644
--- a/resources/lang/en/errors.php
+++ b/resources/lang/en/errors.php
@@ -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.',
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index 78f86b68e..bb542a588 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -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.',
diff --git a/resources/views/auth/invite-set-password.blade.php b/resources/views/auth/invite-set-password.blade.php
new file mode 100644
index 000000000..807bd417f
--- /dev/null
+++ b/resources/views/auth/invite-set-password.blade.php
@@ -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
diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php
index 3d073b2c8..32b717ec8 100644
--- a/resources/views/users/form.blade.php
+++ b/resources/views/users/form.blade.php
@@ -48,23 +48,40 @@
 @endif
 
 @if($authMethod === 'standard')
-    <div>
+    <div new-user-password>
         <label class="setting-list-label">{{ trans('settings.users_password') }}</label>
-        <p class="small">{{ trans('settings.users_password_desc') }}</p>
-        @if(isset($model))
+
+        @if(!isset($model))
             <p class="small">
-                {{ trans('settings.users_password_warning') }}
+                {{ 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 class="grid half mt-m gap-xl">
-            <div>
-                <label for="password">{{ trans('auth.password') }}</label>
-                @include('form.password', ['name' => 'password'])
-            </div>
-            <div>
-                <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
-                @include('form.password', ['name' => 'password-confirm'])
+
+        <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">
+                    {{ trans('settings.users_password_warning') }}
+                </p>
+            @endif
+            <div class="grid half mt-m gap-xl">
+                <div>
+                    <label for="password">{{ trans('auth.password') }}</label>
+                    @include('form.password', ['name' => 'password'])
+                </div>
+                <div>
+                    <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
+                    @include('form.password', ['name' => 'password-confirm'])
+                </div>
             </div>
         </div>
+
     </div>
 @endif
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index 2530a3cb6..d9fdc7455 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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');