--- /dev/null
+<?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;
+ }
+
+}
<?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;
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();
}
];
$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';
$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);
$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(),
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,
'setting-app-color-picker': settingAppColorPicker,
'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager,
+ 'new-user-password': newUserPassword,
};
window.components = {};
--- /dev/null
+
+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
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);
}
}
// 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
'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.',
'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.',
--- /dev/null
+@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
@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
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');