diff --git a/app/Http/Controllers/AccountApiController.php b/app/Http/Controllers/AccountApiController.php index 0de2498139..f36252bc8d 100644 --- a/app/Http/Controllers/AccountApiController.php +++ b/app/Http/Controllers/AccountApiController.php @@ -19,6 +19,8 @@ use App\Ninja\Transformers\UserAccountTransformer; use App\Http\Controllers\BaseAPIController; use Swagger\Annotations as SWG; +use App\Events\UserSignedUp; +use App\Http\Requests\RegisterRequest; use App\Http\Requests\UpdateAccountRequest; class AccountApiController extends BaseAPIController @@ -32,13 +34,19 @@ class AccountApiController extends BaseAPIController $this->accountRepo = $accountRepo; } + public function register(RegisterRequest $request) + { + $account = $this->accountRepo->create($request->first_name, $request->last_name, $request->email, $request->password); + $user = $account->users()->first(); + + Auth::login($user, true); + event(new UserSignedUp()); + + return $this->processLogin($request); + } + public function login(Request $request) { - if ( ! env(API_SECRET) || $request->api_secret !== env(API_SECRET)) { - sleep(ERROR_DELAY); - return $this->errorResponse(['message'=>'Invalid secret'],401); - } - if (Auth::attempt(['email' => $request->email, 'password' => $request->password])) { return $this->processLogin($request); } else { diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 8d4af20011..a0fb51b746 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -26,7 +26,7 @@ use App\Ninja\Repositories\AccountRepository; use App\Ninja\Repositories\ReferralRepository; use App\Ninja\Mailers\UserMailer; use App\Ninja\Mailers\ContactMailer; -use App\Events\UserLoggedIn; +use App\Events\UserSignedUp; use App\Events\UserSettingsChanged; use App\Services\AuthService; @@ -100,7 +100,7 @@ class AccountController extends BaseController } Auth::login($user, true); - event(new UserLoggedIn()); + event(new UserSignedUp()); $redirectTo = Input::get('redirect_to') ?: 'invoices/create'; @@ -151,7 +151,7 @@ class AccountController extends BaseController } elseif ($section == ACCOUNT_INVOICE_DESIGN || $section == ACCOUNT_CUSTOMIZE_DESIGN) { return self::showInvoiceDesign($section); } elseif ($section == ACCOUNT_CLIENT_PORTAL) { - return self::showClientViewStyling(); + return self::showClientPortal(); } elseif ($section === ACCOUNT_TEMPLATES_AND_REMINDERS) { return self::showTemplates(); } elseif ($section === ACCOUNT_PRODUCTS) { @@ -414,7 +414,7 @@ class AccountController extends BaseController return View::make("accounts.{$section}", $data); } - private function showClientViewStyling() + private function showClientPortal() { $account = Auth::user()->account->load('country'); $css = $account->client_view_css ? $account->client_view_css : ''; @@ -430,6 +430,8 @@ class AccountController extends BaseController $data = [ 'client_view_css' => $css, + 'enable_portal_password' => $account->enable_portal_password, + 'send_portal_password' => $account->send_portal_password, 'title' => trans("texts.client_portal"), 'section' => ACCOUNT_CLIENT_PORTAL, 'account' => $account, @@ -545,7 +547,11 @@ class AccountController extends BaseController $account = Auth::user()->account; $account->client_view_css = $sanitized_css; - $account->enable_client_portal = Input::get('enable_client_portal') ? true : false; + + $account->enable_client_portal = !!Input::get('enable_client_portal'); + $account->enable_portal_password = !!Input::get('enable_portal_password'); + $account->send_portal_password = !!Input::get('send_portal_password'); + $account->save(); Session::flash('message', trans('texts.updated_settings')); diff --git a/app/Http/Controllers/ClientAuth/AuthController.php b/app/Http/Controllers/ClientAuth/AuthController.php new file mode 100644 index 0000000000..c88c8a4b85 --- /dev/null +++ b/app/Http/Controllers/ClientAuth/AuthController.php @@ -0,0 +1,79 @@ +first(); + if ($invitation && !$invitation->is_deleted) { + $invoice = $invitation->invoice; + $client = $invoice->client; + $account = $client->account; + + $data['hideLogo'] = $account->isWhiteLabel(); + $data['clientViewCSS'] = $account->clientViewCSS(); + $data['clientFontUrl'] = $account->getFontsUrl(); + } + } + + return view('clientauth.login')->with($data); + } + + /** + * Get the needed authorization credentials from the request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function getCredentials(Request $request) + { + $credentials = $request->only('password'); + $credentials['id'] = null; + + $invitation_key = session('invitation_key'); + if($invitation_key){ + $invitation = Invitation::where('invitation_key', '=', $invitation_key)->first(); + if ($invitation && !$invitation->is_deleted) { + $credentials['id'] = $invitation->contact_id; + } + } + + return $credentials; + } + + /** + * Validate the user login request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function validateLogin(Request $request) + { + $this->validate($request, [ + 'password' => 'required', + ]); + } +} diff --git a/app/Http/Controllers/ClientAuth/PasswordController.php b/app/Http/Controllers/ClientAuth/PasswordController.php new file mode 100644 index 0000000000..beefb01612 --- /dev/null +++ b/app/Http/Controllers/ClientAuth/PasswordController.php @@ -0,0 +1,197 @@ +middleware('guest'); + Config::set("auth.defaults.passwords","client"); + } + + public function showLinkRequestForm() + { + $data = array(); + $invitation_key = session('invitation_key'); + if($invitation_key){ + $invitation = Invitation::where('invitation_key', '=', $invitation_key)->first(); + if ($invitation && !$invitation->is_deleted) { + $invoice = $invitation->invoice; + $client = $invoice->client; + $account = $client->account; + + $data['hideLogo'] = $account->isWhiteLabel(); + $data['clientViewCSS'] = $account->clientViewCSS(); + $data['clientFontUrl'] = $account->getFontsUrl(); + } + } + + return view('clientauth.password')->with($data); + } + + /** + * Send a reset link to the given user. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function sendResetLinkEmail(Request $request) + { + $broker = $this->getBroker(); + + $contact_id = null; + $invitation_key = session('invitation_key'); + if($invitation_key){ + $invitation = Invitation::where('invitation_key', '=', $invitation_key)->first(); + if ($invitation && !$invitation->is_deleted) { + $contact_id = $invitation->contact_id; + } + } + + $response = Password::broker($broker)->sendResetLink(array('id'=>$contact_id), function (Message $message) { + $message->subject($this->getEmailSubject()); + }); + + switch ($response) { + case Password::RESET_LINK_SENT: + return $this->getSendResetLinkEmailSuccessResponse($response); + + case Password::INVALID_USER: + default: + return $this->getSendResetLinkEmailFailureResponse($response); + } + } + + /** + * Display the password reset view for the given token. + * + * If no token is present, display the link request form. + * + * @param \Illuminate\Http\Request $request + * @param string|null $invitation_key + * @param string|null $token + * @return \Illuminate\Http\Response + */ + public function showResetForm(Request $request, $invitation_key = null, $token = null) + { + if (is_null($token)) { + return $this->getEmail(); + } + + $data = compact('token', 'invitation_key'); + $invitation_key = session('invitation_key'); + if($invitation_key){ + $invitation = Invitation::where('invitation_key', '=', $invitation_key)->first(); + if ($invitation && !$invitation->is_deleted) { + $invoice = $invitation->invoice; + $client = $invoice->client; + $account = $client->account; + + $data['hideLogo'] = $account->isWhiteLabel(); + $data['clientViewCSS'] = $account->clientViewCSS(); + $data['clientFontUrl'] = $account->getFontsUrl(); + } + } + + return view('clientauth.reset')->with($data); + } + + + + /** + * Display the password reset view for the given token. + * + * If no token is present, display the link request form. + * + * @param \Illuminate\Http\Request $request + * @param string|null $invitation_key + * @param string|null $token + * @return \Illuminate\Http\Response + */ + public function getReset(Request $request, $invitation_key = null, $token = null) + { + return $this->showResetForm($request, $invitation_key, $token); + } + + /** + * Reset the given user's password. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function reset(Request $request) + { + $this->validate($request, $this->getResetValidationRules()); + + $credentials = $request->only( + 'password', 'password_confirmation', 'token' + ); + + $credentials['id'] = null; + + $invitation_key = $request->input('invitation_key'); + if($invitation_key){ + $invitation = Invitation::where('invitation_key', '=', $invitation_key)->first(); + if ($invitation && !$invitation->is_deleted) { + $credentials['id'] = $invitation->contact_id; + } + } + + $broker = $this->getBroker(); + + $response = Password::broker($broker)->reset($credentials, function ($user, $password) { + $this->resetPassword($user, $password); + }); + + switch ($response) { + case Password::PASSWORD_RESET: + return $this->getResetSuccessResponse($response); + + default: + return $this->getResetFailureResponse($request, $response); + } + } + + /** + * Get the password reset validation rules. + * + * @return array + */ + protected function getResetValidationRules() + { + return [ + 'token' => 'required', + 'password' => 'required|confirmed|min:6', + ]; + } +} diff --git a/app/Http/Middleware/ApiCheck.php b/app/Http/Middleware/ApiCheck.php index 5632e7de4e..63e370cf41 100644 --- a/app/Http/Middleware/ApiCheck.php +++ b/app/Http/Middleware/ApiCheck.php @@ -21,11 +21,15 @@ class ApiCheck { */ public function handle($request, Closure $next) { - $loggingIn = $request->is('api/v1/login'); + $loggingIn = $request->is('api/v1/login') || $request->is('api/v1/register'); $headers = Utils::getApiHeaders(); if ($loggingIn) { - // do nothing + // check API secret + if ( ! $request->api_secret || ! env(API_SECRET) || ! hash_equals($request->api_secret, env(API_SECRET))) { + sleep(ERROR_DELAY); + return Response::json('Invalid secret', 403, $headers); + } } else { // check for a valid token $token = AccountToken::where('token', '=', Request::header('X-Ninja-Token'))->first(['id', 'user_id']); @@ -34,7 +38,7 @@ class ApiCheck { Auth::loginUsingId($token->user_id); Session::set('token_id', $token->id); } else { - sleep(3); + sleep(ERROR_DELAY); return Response::json('Invalid token', 403, $headers); } } diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 1780b299f0..9d5d854944 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -1,28 +1,13 @@ auth = $auth; - } - /** * Handle an incoming request. * @@ -30,9 +15,46 @@ class Authenticate { * @param \Closure $next * @return mixed */ - public function handle($request, Closure $next) + public function handle($request, Closure $next, $guard = 'user') { - if ($this->auth->guest()) + $authenticated = Auth::guard($guard)->check(); + + if($guard == 'client' && !empty($request->invitation_key)){ + $old_key = session('invitation_key'); + if($old_key && $old_key != $request->invitation_key){ + if($this->getInvitationContactId($old_key) != $this->getInvitationContactId($request->invitation_key)){ + // This is a different client; reauthenticate + $authenticated = false; + Auth::guard($guard)->logout(); + } + } + Session::put('invitation_key', $request->invitation_key); + } + + if($guard=='client'){ + $invitation_key = session('invitation_key'); + $account_id = $this->getInvitationAccountId($invitation_key); + + if(Auth::guard('user')->check() && Auth::user('user')->account_id === $account_id){ + // This is an admin; let them pretend to be a client + $authenticated = true; + } + + // Does this account require portal passwords? + $account = Account::whereId($account_id)->first(); + if(!$account->enable_portal_password || !$account->isPro()){ + $authenticated = true; + } + + if(!$authenticated){ + $contact = Contact::whereId($this->getInvitationContactId($invitation_key))->first(); + if($contact && !$contact->password){ + $authenticated = true; + } + } + } + + if (!$authenticated) { if ($request->ajax()) { @@ -40,11 +62,30 @@ class Authenticate { } else { - return redirect()->guest('/login'); + return redirect()->guest($guard=='client'?'/client/login':'/login'); } } return $next($request); } - + + protected function getInvitation($key){ + $invitation = Invitation::where('invitation_key', '=', $key)->first(); + if ($invitation && !$invitation->is_deleted) { + return $invitation; + } + else return null; + } + + protected function getInvitationContactId($key){ + $invitation = $this->getInvitation($key); + + return $invitation?$invitation->contact_id:null; + } + + protected function getInvitationAccountId($key){ + $invitation = $this->getInvitation($key); + + return $invitation?$invitation->account_id:null; + } } diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000000..8709d42cc0 --- /dev/null +++ b/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,35 @@ + 'required|unique:users', + 'first_name' => 'required', + 'last_name' => 'required', + 'password' => 'required', + ]; + + return $rules; + } +} diff --git a/app/Http/routes.php b/app/Http/routes.php index 0e695c6fe5..0c9aef7c1a 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -35,17 +35,20 @@ Route::get('/keep_alive', 'HomeController@keepAlive'); Route::post('/get_started', 'AccountController@getStarted'); // Client visible pages -Route::get('view/{invitation_key}', 'PublicClientController@view'); -Route::get('download/{invitation_key}', 'PublicClientController@download'); -Route::get('view', 'HomeController@viewLogo'); -Route::get('approve/{invitation_key}', 'QuoteController@approve'); -Route::get('payment/{invitation_key}/{payment_type?}', 'PaymentController@show_payment'); -Route::post('payment/{invitation_key}', 'PaymentController@do_payment'); -Route::get('complete', 'PaymentController@offsite_payment'); -Route::get('client/quotes', 'PublicClientController@quoteIndex'); -Route::get('client/invoices', 'PublicClientController@invoiceIndex'); -Route::get('client/payments', 'PublicClientController@paymentIndex'); -Route::get('client/dashboard', 'PublicClientController@dashboard'); +Route::group(['middleware' => 'auth:client'], function() { + Route::get('view/{invitation_key}', 'PublicClientController@view'); + Route::get('download/{invitation_key}', 'PublicClientController@download'); + Route::get('view', 'HomeController@viewLogo'); + Route::get('approve/{invitation_key}', 'QuoteController@approve'); + Route::get('payment/{invitation_key}/{payment_type?}', 'PaymentController@show_payment'); + Route::post('payment/{invitation_key}', 'PaymentController@do_payment'); + Route::get('complete', 'PaymentController@offsite_payment'); + Route::get('client/quotes', 'PublicClientController@quoteIndex'); + Route::get('client/invoices', 'PublicClientController@invoiceIndex'); + Route::get('client/payments', 'PublicClientController@paymentIndex'); + Route::get('client/dashboard', 'PublicClientController@dashboard'); +}); + Route::get('api/client.quotes', array('as'=>'api.client.quotes', 'uses'=>'PublicClientController@quoteDatatable')); Route::get('api/client.invoices', array('as'=>'api.client.invoices', 'uses'=>'PublicClientController@invoiceDatatable')); Route::get('api/client.payments', array('as'=>'api.client.payments', 'uses'=>'PublicClientController@paymentDatatable')); @@ -76,6 +79,15 @@ Route::get('/password/reset/{token}', array('as' => 'forgot', 'uses' => 'Auth\Pa Route::post('/password/reset', array('as' => 'forgot', 'uses' => 'Auth\PasswordController@postReset')); Route::get('/user/confirm/{code}', 'UserController@confirm'); +// Client auth +Route::get('/client/login', array('as' => 'login', 'uses' => 'ClientAuth\AuthController@getLogin')); +Route::post('/client/login', array('as' => 'login', 'uses' => 'ClientAuth\AuthController@postLogin')); +Route::get('/client/logout', array('as' => 'logout', 'uses' => 'ClientAuth\AuthController@getLogout')); +Route::get('/client/forgot', array('as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getEmail')); +Route::post('/client/forgot', array('as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postEmail')); +Route::get('/client/password/reset/{invitation_key}/{token}', array('as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@getReset')); +Route::post('/client/password/reset', array('as' => 'forgot', 'uses' => 'ClientAuth\PasswordController@postReset')); + if (Utils::isNinja()) { Route::post('/signup/register', 'AccountController@doRegister'); @@ -87,7 +99,7 @@ if (Utils::isReseller()) { Route::post('/reseller_stats', 'AppController@stats'); } -Route::group(['middleware' => 'auth'], function() { +Route::group(['middleware' => 'auth:user'], function() { Route::get('dashboard', 'DashboardController@index'); Route::get('view_archive/{entity_type}/{visible}', 'AccountController@setTrashVisible'); Route::get('hide_message', 'HomeController@hideMessage'); @@ -215,6 +227,7 @@ Route::group(['middleware' => 'api', 'prefix' => 'api/v1'], function() { Route::get('ping', 'ClientApiController@ping'); Route::post('login', 'AccountApiController@login'); + Route::post('register', 'AccountApiController@register'); Route::get('static', 'AccountApiController@getStaticData'); Route::get('accounts', 'AccountApiController@show'); Route::put('accounts', 'AccountApiController@update'); diff --git a/app/Models/Client.php b/app/Models/Client.php index 61194fe93c..9223264711 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -154,7 +154,15 @@ class Client extends EntityModel $contact = Contact::createNew(); $contact->send_invoice = true; } - + + if (!Utils::isPro() || $this->account->enable_portal_password){ + if(!empty($data['password']) && $data['password']!='-%unchanged%-'){ + $contact->password = bcrypt($data['password']); + } else if(empty($data['password'])){ + $contact->password = null; + } + } + $contact->fill($data); $contact->is_primary = $isPrimary; diff --git a/app/Models/Contact.php b/app/Models/Contact.php index a95f40bab0..9c86c4ce5b 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -3,10 +3,14 @@ use HTML; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Auth\Authenticatable; +use Illuminate\Auth\Passwords\CanResetPassword; +use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; +use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; -class Contact extends EntityModel +class Contact extends EntityModel implements AuthenticatableContract, CanResetPasswordContract { - use SoftDeletes; + use SoftDeletes, Authenticatable, CanResetPassword; protected $dates = ['deleted_at']; protected $fillable = [ diff --git a/app/Ninja/Mailers/ContactMailer.php b/app/Ninja/Mailers/ContactMailer.php index 87e3c82c02..e526300a5d 100644 --- a/app/Ninja/Mailers/ContactMailer.php +++ b/app/Ninja/Mailers/ContactMailer.php @@ -27,6 +27,7 @@ class ContactMailer extends Mailer 'firstName', 'invoice', 'quote', + 'password', 'viewLink', 'viewButton', 'paymentLink', @@ -109,6 +110,13 @@ class ContactMailer extends Mailer 'invitation' => $invitation, 'amount' => $invoice->getRequestedAmount() ]; + + if (empty($invitation->contact->password) && $account->isPro() && $account->enable_portal_password && $account->send_portal_password) { + // The contact needs a password + $variables['password'] = $password = $this->generatePassword(); + $invitation->contact->password = bcrypt($password); + $invitation->contact->save(); + } $data = [ 'body' => $this->processVariables($body, $variables), @@ -143,6 +151,28 @@ class ContactMailer extends Mailer return $response; } } + + protected function generatePassword($length = 9) + { + $sets = array( + 'abcdefghjkmnpqrstuvwxyz', + 'ABCDEFGHJKMNPQRSTUVWXYZ', + '23456789', + ); + $all = ''; + $password = ''; + foreach($sets as $set) + { + $password .= $set[array_rand(str_split($set))]; + $all .= $set; + } + $all = str_split($all); + for($i = 0; $i < $length - count($sets); $i++) + $password .= $all[array_rand($all)]; + $password = str_shuffle($password); + + return $password; + } public function sendPaymentConfirmation(Payment $payment) { @@ -232,6 +262,7 @@ class ContactMailer extends Mailer $client = $data['client']; $invitation = $data['invitation']; $invoice = $invitation->invoice; + $passwordHTML = isset($data['password'])?'

'.trans('texts.password').': '.$data['password'].'

':false; $variables = [ '$footer' => $account->getEmailFooter(), @@ -245,10 +276,11 @@ class ContactMailer extends Mailer '$invoice' => $invoice->invoice_number, '$quote' => $invoice->invoice_number, '$link' => $invitation->getLink(), - '$viewLink' => $invitation->getLink(), - '$viewButton' => Form::emailViewButton($invitation->getLink(), $invoice->getEntityType()), - '$paymentLink' => $invitation->getLink('payment'), - '$paymentButton' => Form::emailPaymentButton($invitation->getLink('payment')), + '$password' => $passwordHTML, + '$viewLink' => $invitation->getLink().'$password', + '$viewButton' => Form::emailViewButton($invitation->getLink(), $invoice->getEntityType()).'$password', + '$paymentLink' => $invitation->getLink('payment').'$password', + '$paymentButton' => Form::emailPaymentButton($invitation->getLink('payment')).'$password', '$customClient1' => $account->custom_client_label1, '$customClient2' => $account->custom_client_label2, '$customInvoice1' => $account->custom_invoice_text_label1, @@ -259,11 +291,22 @@ class ContactMailer extends Mailer foreach (Gateway::$paymentTypes as $type) { $camelType = Gateway::getPaymentTypeName($type); $type = Utils::toSnakeCase($camelType); - $variables["\${$camelType}Link"] = $invitation->getLink() . "/{$type}"; + $variables["\${$camelType}Link"] = $invitation->getLink('payment') . "/{$type}"; $variables["\${$camelType}Button"] = Form::emailPaymentButton($invitation->getLink('payment') . "/{$type}"); } - + + $includesPasswordPlaceholder = strpos($template, '$password') !== false; + $str = str_replace(array_keys($variables), array_values($variables), $template); + + if(!$includesPasswordPlaceholder && $passwordHTML){ + $pos = strrpos($str, '$password'); + if($pos !== false) + { + $str = substr_replace($str, $passwordHTML, $pos, 9/* length of "$password" */); + } + } + $str = str_replace('$password', '', $str); $str = autolink($str, 100); return $str; diff --git a/app/Ninja/Repositories/AccountRepository.php b/app/Ninja/Repositories/AccountRepository.php index c215f69a97..ff85efb76e 100644 --- a/app/Ninja/Repositories/AccountRepository.php +++ b/app/Ninja/Repositories/AccountRepository.php @@ -59,7 +59,7 @@ class AccountRepository } $user->confirmed = !Utils::isNinja(); - $user->registered = !Utils::isNinja() && $user->email; + $user->registered = !Utils::isNinja() || $email; if (!$user->confirmed) { $user->confirmation_code = str_random(RANDOM_KEY_LENGTH); diff --git a/config/auth.php b/config/auth.php index 6bb5e47c78..7b08fd4731 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,7 +1,6 @@ [ - 'guard' => 'web', + 'guard' => 'user', 'passwords' => 'users', ], @@ -36,10 +35,15 @@ return [ */ 'guards' => [ - 'web' => [ + 'user' => [ 'driver' => 'session', 'provider' => 'users', ], + + 'client' => [ + 'driver' => 'session', + 'provider' => 'client', + ], 'api' => [ 'driver' => 'token', @@ -69,11 +73,11 @@ return [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + + 'client' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Contact::class, + ] ], /* @@ -98,7 +102,13 @@ return [ 'passwords' => [ 'users' => [ 'provider' => 'users', - 'email' => 'emails.password', //auth.emails.password + 'email' => 'emails.password', + 'table' => 'password_resets', + 'expire' => 60, + ], + 'client' => [ + 'provider' => 'client', + 'email' => 'emails.client_password', 'table' => 'password_resets', 'expire' => 60, ], diff --git a/database/migrations/2016_02_25_152948_add_client_password.php b/database/migrations/2016_02_25_152948_add_client_password.php new file mode 100644 index 0000000000..49fcae7fcb --- /dev/null +++ b/database/migrations/2016_02_25_152948_add_client_password.php @@ -0,0 +1,46 @@ +boolean('enable_portal_password')->default(0); + $table->boolean('send_portal_password')->default(0); + }); + + Schema::table('contacts', function ($table) { + $table->string('password', 255)->nullable(); + $table->boolean('confirmation_code', 255)->nullable(); + $table->boolean('remember_token', 100)->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('accounts', function ($table) { + $table->dropColumn('enable_portal_password'); + $table->dropColumn('send_portal_password'); + }); + + Schema::table('contacts', function ($table) { + $table->dropColumn('password'); + $table->dropColumn('confirmation_code'); + $table->dropColumn('remember_token'); + }); + } + +} diff --git a/database/seeds/ConstantsSeeder.php b/database/seeds/ConstantsSeeder.php index ec2a94d491..e027416843 100644 --- a/database/seeds/ConstantsSeeder.php +++ b/database/seeds/ConstantsSeeder.php @@ -177,7 +177,7 @@ class ConstantsSeeder extends Seeder 'America/Santiago' => "(GMT-04:00) Santiago", 'Canada/Newfoundland' => "(GMT-03:30) Newfoundland", 'America/Buenos_Aires' => "(GMT-03:00) Buenos Aires", - 'Greenland' => "(GMT-03:00) Greenland", + 'America/Godthab' => "(GMT-03:00) Greenland", 'Atlantic/Stanley' => "(GMT-02:00) Stanley", 'Atlantic/Azores' => "(GMT-01:00) Azores", 'Atlantic/Cape_Verde' => "(GMT-01:00) Cape Verde Is.", diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index c330dbd894..1e83a7821b 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1051,7 +1051,12 @@ $LANG = array( 'recurring_invoice_number_prefix_help' => 'Speciy a prefix to be added to the invoice number for recurring invoices. The default value is \'R\'.', 'enable_client_portal' => 'Dashboard', 'enable_client_portal_help' => 'Show/hide the dashboard page in the client portal.', - + + // Client Passwords + 'enable_portal_password'=>'Password protect invoices', + 'enable_portal_password_help'=>'Allows you to set a password for each contact. If a password is set, the contact will be required to enter a password before viewing invoices.', + 'send_portal_password'=>'Generate password automatically', + 'send_portal_password_help'=>'If no password is set, one will be generated and sent with the first invoice.', ); return $LANG; diff --git a/resources/views/accounts/client_portal.blade.php b/resources/views/accounts/client_portal.blade.php index 88962e4d0d..745574ad72 100644 --- a/resources/views/accounts/client_portal.blade.php +++ b/resources/views/accounts/client_portal.blade.php @@ -1,69 +1,86 @@ @extends('header') @section('head') - @parent +@parent - + @stop @section('content') - @parent - - {!! Former::open_for_files() - ->addClass('warn-on-exit') !!} +@parent - {!! Former::populateField('enable_client_portal', intval($account->enable_client_portal)) !!} - {!! Former::populateField('client_view_css', $client_view_css) !!} +{!! Former::open_for_files() +->addClass('warn-on-exit') !!} - @if (!Utils::isNinja() && !Auth::user()->account->isWhiteLabel()) -

-
- {!! trans('texts.white_label_custom_css', ['link'=>''.trans('texts.white_label_purchase_link').'']) !!} -
-
- @endif +{!! Former::populateField('enable_client_portal', intval($account->enable_client_portal)) !!} +{!! Former::populateField('client_view_css', $client_view_css) !!} +{!! Former::populateField('enable_portal_password', intval($enable_portal_password)) !!} +{!! Former::populateField('send_portal_password', intval($send_portal_password)) !!} - @include('accounts.nav', ['selected' => ACCOUNT_CLIENT_PORTAL]) - -
-
- -
-
-

{!! trans('texts.client_portal') !!}

-
-
-
- {!! Former::checkbox('enable_client_portal') - ->text(trans('texts.enable')) - ->help(trans('texts.enable_client_portal_help')) !!} -
-
-
- -
-
-

{!! trans('texts.custom_css') !!}

-
-
-
- {!! Former::textarea('client_view_css') - ->label(trans('texts.custom_css')) - ->rows(10) - ->raw() - ->autofocus() - ->maxlength(60000) - ->style("min-width:100%;max-width:100%;font-family:'Roboto Mono', 'Lucida Console', Monaco, monospace;font-size:14px;'") !!} -
-
-
-
-
- +@if (!Utils::isNinja() && !Auth::user()->account->isWhiteLabel()) +
- {!! Button::success(trans('texts.save'))->submit()->large()->appendIcon(Icon::create('floppy-disk')) !!} + {!! trans('texts.white_label_custom_css', ['link'=>''.trans('texts.white_label_purchase_link').'']) !!}
+
+@endif - {!! Former::close() !!} +@include('accounts.nav', ['selected' => ACCOUNT_CLIENT_PORTAL]) +
+
+
+

{!! trans('texts.client_portal') !!}

+
+
+
+ {!! Former::checkbox('enable_client_portal') + ->text(trans('texts.enable')) + ->help(trans('texts.enable_client_portal_help')) !!} +
+
+ {!! Former::checkbox('enable_portal_password') + ->text(trans('texts.enable_portal_password')) + ->help(trans('texts.enable_portal_password_help')) + ->label(' ') !!} +
+
+ {!! Former::checkbox('send_portal_password') + ->text(trans('texts.send_portal_password')) + ->help(trans('texts.send_portal_password_help')) + ->label(' ') !!} +
+
+
+
+
+

{!! trans('texts.custom_css') !!}

+
+
+
+ {!! Former::textarea('client_view_css') + ->label(trans('texts.custom_css')) + ->rows(10) + ->raw() + ->autofocus() + ->maxlength(60000) + ->style("min-width:100%;max-width:100%;font-family:'Roboto Mono', 'Lucida Console', Monaco, monospace;font-size:14px;'") !!} +
+
+
+
+ +
+ {!! Button::success(trans('texts.save'))->submit()->large()->appendIcon(Icon::create('floppy-disk')) !!} +
+ +{!! Former::close() !!} + @stop \ No newline at end of file diff --git a/resources/views/accounts/templates_and_reminders.blade.php b/resources/views/accounts/templates_and_reminders.blade.php index 110665b3f6..22a3a8a4a0 100644 --- a/resources/views/accounts/templates_and_reminders.blade.php +++ b/resources/views/accounts/templates_and_reminders.blade.php @@ -200,6 +200,7 @@ } var keys = {!! json_encode(\App\Ninja\Mailers\ContactMailer::$variableFields) !!}; + var passwordHtml = "{!! $account->isPro() && $account->enable_portal_password && $account->send_portal_password?'

'.trans('texts.password').': 6h2NWNdw6

':'' !!}"; var vals = [ {!! json_encode($emailFooter) !!}, "{{ $account->getDisplayName() }}", @@ -211,10 +212,11 @@ "First Name", "0001", "0001", - "{{ URL::to('/view/...') }}", - '{!! Form::flatButton('view_invoice', '#0b4d78') !!}', - "{{ URL::to('/payment/...') }}", - '{!! Form::flatButton('pay_now', '#36c157') !!}', + passwordHtml, + "{{ URL::to('/view/...') }}$password", + '{!! Form::flatButton('view_invoice', '#0b4d78') !!}$password', + "{{ URL::to('/payment/...') }}$password", + '{!! Form::flatButton('pay_now', '#36c157') !!}$password', ]; // Add blanks for custom values @@ -230,10 +232,18 @@ {!! "vals.push('" . Form::flatButton('pay_now', '#36c157') . "');" !!} @endforeach + var includesPasswordPlaceholder = str.indexOf('$password') != -1; + for (var i=0; idata_bind("value: last_name, valueUpdate: 'afterkeydown'") !!} {!! Former::text('email')->data_bind("value: email, valueUpdate: 'afterkeydown'") !!} {!! Former::text('phone')->data_bind("value: phone, valueUpdate: 'afterkeydown'") !!} + @if ($account->isPro() && $account->enable_portal_password) + {!! Former::password('password')->data_bind("value: password()?'-%unchanged%-':'', valueUpdate: 'afterkeydown'") !!} + @endif

diff --git a/resources/views/clientauth/login.blade.php b/resources/views/clientauth/login.blade.php new file mode 100644 index 0000000000..0469e882b0 --- /dev/null +++ b/resources/views/clientauth/login.blade.php @@ -0,0 +1,120 @@ +@extends('public.header') + +@section('head') +@parent + + +@endsection + +@section('body') +
+ + @include('partials.warn_session', ['redirectTo' => '/client/login']) + + {!! Former::open('client/login') + ->rules(['password' => 'required']) + ->addClass('form-signin') !!} + {{ Former::populateField('remember', 'true') }} + + +
+

+ {!! Former::password('password')->placeholder(trans('texts.password'))->raw() !!} + {!! Former::hidden('remember')->raw() !!} +

+ +

{!! Button::success(trans('texts.login')) + ->withAttributes(['id' => 'loginButton']) + ->large()->submit()->block() !!}

+ + + + + @if (count($errors->all())) +
+ @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
    + @endif + + @if (Session::has('warning')) +
    {{ Session::get('warning') }}
    + @endif + + @if (Session::has('message')) +
    {{ Session::get('message') }}
    + @endif + + @if (Session::has('error')) +
  • {{ Session::get('error') }}
  • + @endif + +
    + + {!! Former::close() !!} +
    +@endsection \ No newline at end of file diff --git a/resources/views/clientauth/password.blade.php b/resources/views/clientauth/password.blade.php new file mode 100644 index 0000000000..7216765b4d --- /dev/null +++ b/resources/views/clientauth/password.blade.php @@ -0,0 +1,108 @@ +@extends('public.header') + +@section('head') +@parent + + +@stop + +@section('body') +
    + +{!! Former::open('client/forgot')->addClass('form-signin') !!} + +
    + +

    {!! Button::success(trans('texts.send_email'))->large()->submit()->block() !!}

    + + @if (count($errors->all())) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
    + @endif + + @if (session('status')) +
    + {{ session('status') }} +
    + @endif + + + @if (Session::has('warning')) +
    {{ Session::get('warning') }}
    + @endif + + @if (Session::has('message')) +
    {{ Session::get('message') }}
    + @endif + + @if (Session::has('error')) +
    {{ Session::get('error') }}
    + @endif + + {!! Former::close() !!} + +
    +
    + + + +@stop \ No newline at end of file diff --git a/resources/views/clientauth/reset.blade.php b/resources/views/clientauth/reset.blade.php new file mode 100644 index 0000000000..bd2f5f2d2b --- /dev/null +++ b/resources/views/clientauth/reset.blade.php @@ -0,0 +1,111 @@ +@extends('public.header') + +@section('head') +@parent + + +@stop + +@section('body') +
    + + {!! Former::open('/client/password/reset')->addClass('form-signin')->rules(array( + 'password' => 'required', + 'password_confirmation' => 'required', + )) !!} + + +
    + + + + +

    + {!! Former::password('password')->placeholder(trans('texts.password'))->raw() !!} + {!! Former::password('password_confirmation')->placeholder(trans('texts.confirm_password'))->raw() !!} + +

    + +

    {!! Button::success(trans('texts.save'))->large()->submit()->block() !!}

    + + + @if (count($errors->all())) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
    + @endif + + + @if (Session::has('warning')) +
    {{ Session::get('warning') }}
    + @endif + + @if (Session::has('message')) +
    {{ Session::get('message') }}
    + @endif + + @if (Session::has('error')) +
    {{ Session::get('error') }}
    + @endif + + + {!! Former::close() !!} +
    + +
    + +@stop \ No newline at end of file diff --git a/resources/views/clients/edit.blade.php b/resources/views/clients/edit.blade.php index 72835f170f..1dc84ccd77 100644 --- a/resources/views/clients/edit.blade.php +++ b/resources/views/clients/edit.blade.php @@ -93,7 +93,10 @@ attr: {name: 'contacts[' + \$index() + '][email]', id:'email'+\$index()}") !!} {!! Former::text('phone')->data_bind("value: phone, valueUpdate: 'afterkeydown', attr: {name: 'contacts[' + \$index() + '][phone]'}") !!} - + @if ($account->isPro() && $account->enable_portal_password) + {!! Former::password('password')->data_bind("value: password()?'-%unchanged%-':'', valueUpdate: 'afterkeydown', + attr: {name: 'contacts[' + \$index() + '][password]'}") !!} + @endif
    @@ -166,6 +169,7 @@ self.last_name = ko.observable(''); self.email = ko.observable(''); self.phone = ko.observable(''); + self.password = ko.observable(''); if (data) { ko.mapping.fromJS(data, {}, this); diff --git a/resources/views/emails/client_password.blade.php b/resources/views/emails/client_password.blade.php new file mode 100644 index 0000000000..f4b5d0d3b3 --- /dev/null +++ b/resources/views/emails/client_password.blade.php @@ -0,0 +1,26 @@ +@extends('emails.master_user') + +@section('body') +
    + {{ trans('texts.reset_password') }} +
    +   +
    +
    + @include('partials.email_button', [ + 'link' => URL::to("client/password/reset/".session('invitation_key')."/{$token}"), + 'field' => 'reset', + 'color' => '#36c157', + ]) +
    +
    +   +
    + {{ trans('texts.email_signature') }}
    + {{ trans('texts.email_from') }} +
    +   +
    + {{ trans('texts.reset_password_footer') }} +
    +@stop \ No newline at end of file diff --git a/resources/views/invoices/edit.blade.php b/resources/views/invoices/edit.blade.php index 95b38de1ac..9a4c7f0bb3 100644 --- a/resources/views/invoices/edit.blade.php +++ b/resources/views/invoices/edit.blade.php @@ -543,7 +543,10 @@ ->addClass('client-email') !!} {!! Former::text('phone')->data_bind("value: phone, valueUpdate: 'afterkeydown', attr: {name: 'client[contacts][' + \$index() + '][phone]'}") !!} - + @if ($account->isPro() && $account->enable_portal_password) + {!! Former::password('password')->data_bind("value: (typeof password=='function'?password():null)?'-%unchanged%-':'', valueUpdate: 'afterkeydown', + attr: {name: 'client[contacts][' + \$index() + '][password]'}") !!} + @endif
    diff --git a/tests/acceptance/TaxRatesCest.php b/tests/acceptance/TaxRatesCest.php index 209634de6f..1616438d56 100644 --- a/tests/acceptance/TaxRatesCest.php +++ b/tests/acceptance/TaxRatesCest.php @@ -29,6 +29,7 @@ class TaxRatesCest $total = $itemCost; $total += round($itemCost * $itemTaxRate / 100, 2); $total += round($itemCost * $invoiceTaxRate / 100, 2); + $itemTaxRate = number_format($itemTaxRate, 2); // create tax rates $I->amOnPage('/tax_rates/create');