From 6376302ed961bcd4208a655a42479738c1655ce9 Mon Sep 17 00:00:00 2001 From: Joshua Dwire Date: Mon, 29 Feb 2016 16:46:27 -0500 Subject: [PATCH 1/6] Add option for client portal password --- app/Http/Controllers/AccountController.php | 10 +- .../Controllers/Auth/ClientAuthController.php | 127 ++++++++++++++++++ app/Http/routes.php | 24 ++-- app/Models/Client.php | 10 +- app/Models/Contact.php | 8 +- app/Providers/AuthServiceProvider.php | 46 +++++++ config/auth.php | 57 +------- .../2016_02_25_152948_add_client_password.php | 48 +++++++ resources/lang/en/texts.php | 6 + .../views/accounts/client_portal.blade.php | 60 ++++++--- resources/views/clients/edit.blade.php | 5 +- resources/views/invoices/edit.blade.php | 2 +- 12 files changed, 311 insertions(+), 92 deletions(-) create mode 100644 app/Http/Controllers/Auth/ClientAuthController.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 database/migrations/2016_02_25_152948_add_client_password.php diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 6c0f59a3ba..f499c779eb 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -150,7 +150,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) { @@ -398,7 +398,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 : ''; @@ -414,6 +414,9 @@ class AccountController extends BaseController $data = [ 'client_view_css' => $css, + 'enable_portal_password' => $account->enable_portal_password, + 'fill_portal_password' => $account->fill_portal_password, + 'send_portal_password' => $account->send_portal_password, 'title' => trans("texts.client_portal"), 'section' => ACCOUNT_CLIENT_PORTAL, ]; @@ -528,6 +531,9 @@ class AccountController extends BaseController $account = Auth::user()->account; $account->client_view_css = $sanitized_css; + $account->enable_portal_password = Input::get('enable_portal_password'); + $account->fill_portal_password = Input::get('fill_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/Auth/ClientAuthController.php b/app/Http/Controllers/Auth/ClientAuthController.php new file mode 100644 index 0000000000..263dd8e4f7 --- /dev/null +++ b/app/Http/Controllers/Auth/ClientAuthController.php @@ -0,0 +1,127 @@ +auth = $auth; + $this->registrar = $registrar; + $this->accountRepo = $repo; + $this->authService = $authService; + + //$this->middleware('guest', ['except' => 'getLogout']); + } + + public function authLogin($provider, Request $request) + { + return $this->authService->execute($provider, $request->has('code')); + } + + public function authUnlink() + { + $this->accountRepo->unlinkUserFromOauth(Auth::user()); + + Session::flash('message', trans('texts.updated_settings')); + return redirect()->to('/settings/' . ACCOUNT_USER_DETAILS); + } + + public function getLoginWrapper() + { + if (!Utils::isNinja() && !User::count()) { + return redirect()->to('invoice_now'); + } + + return self::getLogin(); + } + + public function postLoginWrapper(Request $request) + { + + $userId = Auth::check() ? Auth::user()->id : null; + $user = User::where('email', '=', $request->input('email'))->first(); + + if ($user && $user->failed_logins >= MAX_FAILED_LOGINS) { + Session::flash('error', trans('texts.invalid_credentials')); + return redirect()->to('login'); + } + + $response = self::postLogin($request); + + if (Auth::check()) { + Event::fire(new UserLoggedIn()); + + $users = false; + // we're linking a new account + if ($request->link_accounts && $userId && Auth::user()->id != $userId) { + $users = $this->accountRepo->associateAccounts($userId, Auth::user()->id); + Session::flash('message', trans('texts.associated_accounts')); + // check if other accounts are linked + } else { + $users = $this->accountRepo->loadAccounts(Auth::user()->id); + } + Session::put(SESSION_USER_ACCOUNTS, $users); + + } elseif ($user) { + $user->failed_logins = $user->failed_logins + 1; + $user->save(); + } + + return $response; + } + + + public function getLogoutWrapper() + { + if (Auth::check() && !Auth::user()->registered) { + $account = Auth::user()->account; + $this->accountRepo->unlinkAccount($account); + $account->forceDelete(); + } + + $response = self::getLogout(); + + Session::flush(); + + return $response; + } +} diff --git a/app/Http/routes.php b/app/Http/routes.php index f7bed59f68..9b5af76986 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -35,17 +35,19 @@ 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'], 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')); diff --git a/app/Models/Client.php b/app/Models/Client.php index 2167af0ddf..987ed45f3e 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/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000000..569762b389 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,46 @@ +app->alias('customerauth', 'App\Auth\CustomerAuthManager'); + $this->app->alias('customerauth.driver', 'App\Auth\SiteGuard'); + $this->app->alias('customerauth.driver', 'App\Contracts\Auth\SiteGuard'); + + parent::register(); + } + + protected function registerAuthenticator() + { + $this->app->singleton('customerauth', function ($app) { + $app['customerauth.loaded'] = true; + + return new CustomerAuthManager($app); + }); + + $this->app->singleton('customerauth.driver', function ($app) { + return $app['customerauth']->driver(); + }); + } + + protected function registerUserResolver() + { + $this->app->bind('Illuminate\Contracts\Auth\Authenticatable', function ($app) { + return $app['customerauth']->user(); + }); + } + + protected function registerRequestRebindHandler() + { + $this->app->rebinding('request', function ($app, $request) { + $request->setUserResolver(function() use ($app) { + return $app['customerauth']->user(); + }); + }); + } +} \ No newline at end of file diff --git a/config/auth.php b/config/auth.php index 1ce67b55a1..12ebcf0337 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,67 +1,12 @@ 'eloquent', - - /* - |-------------------------------------------------------------------------- - | Authentication Model - |-------------------------------------------------------------------------- - | - | When using the "Eloquent" authentication driver, we need to know which - | Eloquent model should be used to retrieve your users. Of course, it - | is often just the "User" model but you may use whatever you like. - | - */ - 'model' => 'App\Models\User', - - /* - |-------------------------------------------------------------------------- - | Authentication Table - |-------------------------------------------------------------------------- - | - | When using the "Database" authentication driver, we need to know which - | table should be used to retrieve your users. We have chosen a basic - | default value but you may easily change it to any table you like. - | - */ - 'table' => 'users', - - /* - |-------------------------------------------------------------------------- - | Password Reset Settings - |-------------------------------------------------------------------------- - | - | Here you may set the options for resetting passwords including the view - | that is your password reset e-mail. You can also set the name of the - | table that maintains all of the reset tokens for your application. - | - | The expire time is the number of minutes that the reset token should be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - */ - 'password' => [ 'email' => 'emails.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..f19f70b729 --- /dev/null +++ b/database/migrations/2016_02_25_152948_add_client_password.php @@ -0,0 +1,48 @@ +boolean('enable_portal_password')->default(0); + $table->boolean('fill_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('fill_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/resources/lang/en/texts.php b/resources/lang/en/texts.php index 0c9bfac165..f888c6887e 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1028,6 +1028,12 @@ $LANG = array( 'user_unconfirmed' => 'Please confirm your account to send emails', 'invalid_contact_email' => 'Invalid contact email', ], + + // Client Passwords + 'client_portal_login_settings'=>'Login', + 'enable_portal_password'=>'Require a password', + 'send_portal_password'=>'Generate password automatically', + 'fill_portal_password'=>'Include password in invoice emails', ); return $LANG; diff --git a/resources/views/accounts/client_portal.blade.php b/resources/views/accounts/client_portal.blade.php index 5ba8fcf5bc..1e1eac04d3 100644 --- a/resources/views/accounts/client_portal.blade.php +++ b/resources/views/accounts/client_portal.blade.php @@ -13,6 +13,7 @@ ->addClass('warn-on-exit') !!} {!! Former::populateField('client_view_css', $client_view_css) !!} + {!! Former::populateField('enable_portal_password', $enable_portal_password) !!} @if (!Utils::isNinja() && !Auth::user()->account->isWhiteLabel())
@@ -26,23 +27,38 @@
- -
-
-

{!! 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;'") !!} -
-
-
+
+
+

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

+
+
+ {!! Former::checkbox('enable_portal_password') + ->text(trans('texts.enable_portal_password')) + ->label(' ') !!} + {!! Former::checkbox('fill_portal_password') + ->text(trans('texts.fill_portal_password')) + ->label(' ') !!} + {!! Former::checkbox('send_portal_password') + ->text(trans('texts.send_portal_password')) + ->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;'") !!} +
+
+
@@ -51,5 +67,13 @@ {!! 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..4419ca45d0 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 (!Utils::isPro() || $account->enable_portal_password) + {!! Former::password('password')->data_bind("value: password()?'-%unchanged%-':'', valueUpdate: 'afterkeydown', + attr: {name: 'contacts[' + \$index() + '][password]'}") !!} + @endif
diff --git a/resources/views/invoices/edit.blade.php b/resources/views/invoices/edit.blade.php index 8745c9d973..5405f9a582 100644 --- a/resources/views/invoices/edit.blade.php +++ b/resources/views/invoices/edit.blade.php @@ -529,7 +529,7 @@ ->addClass('client-email') !!} {!! Former::text('phone')->data_bind("value: phone, valueUpdate: 'afterkeydown', attr: {name: 'client[contacts][' + \$index() + '][phone]'}") !!} - +f
From 793ba76415498399ad75e7474894a848b960d603 Mon Sep 17 00:00:00 2001 From: Joshua Dwire Date: Fri, 4 Mar 2016 22:22:54 -0500 Subject: [PATCH 2/6] Finalize basic client portal password support --- .../Controllers/Auth/ClientAuthController.php | 127 ------------- .../Controllers/ClientAuth/AuthController.php | 62 +++++++ .../ClientAuth/PasswordController.php | 168 ++++++++++++++++++ app/Http/Middleware/Authenticate.php | 88 ++++++--- app/Http/routes.php | 15 +- app/Providers/AuthServiceProvider.php | 46 ----- config/auth.php | 27 ++- resources/views/client.blade.php | 3 + resources/views/clientauth/login.blade.php | 116 ++++++++++++ resources/views/clientauth/password.blade.php | 101 +++++++++++ resources/views/clientauth/reset.blade.php | 104 +++++++++++ resources/views/clients/edit.blade.php | 2 +- .../views/emails/client_password.blade.php | 26 +++ resources/views/invoices/edit.blade.php | 5 +- 14 files changed, 680 insertions(+), 210 deletions(-) delete mode 100644 app/Http/Controllers/Auth/ClientAuthController.php create mode 100644 app/Http/Controllers/ClientAuth/AuthController.php create mode 100644 app/Http/Controllers/ClientAuth/PasswordController.php delete mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 resources/views/clientauth/login.blade.php create mode 100644 resources/views/clientauth/password.blade.php create mode 100644 resources/views/clientauth/reset.blade.php create mode 100644 resources/views/emails/client_password.blade.php diff --git a/app/Http/Controllers/Auth/ClientAuthController.php b/app/Http/Controllers/Auth/ClientAuthController.php deleted file mode 100644 index 263dd8e4f7..0000000000 --- a/app/Http/Controllers/Auth/ClientAuthController.php +++ /dev/null @@ -1,127 +0,0 @@ -auth = $auth; - $this->registrar = $registrar; - $this->accountRepo = $repo; - $this->authService = $authService; - - //$this->middleware('guest', ['except' => 'getLogout']); - } - - public function authLogin($provider, Request $request) - { - return $this->authService->execute($provider, $request->has('code')); - } - - public function authUnlink() - { - $this->accountRepo->unlinkUserFromOauth(Auth::user()); - - Session::flash('message', trans('texts.updated_settings')); - return redirect()->to('/settings/' . ACCOUNT_USER_DETAILS); - } - - public function getLoginWrapper() - { - if (!Utils::isNinja() && !User::count()) { - return redirect()->to('invoice_now'); - } - - return self::getLogin(); - } - - public function postLoginWrapper(Request $request) - { - - $userId = Auth::check() ? Auth::user()->id : null; - $user = User::where('email', '=', $request->input('email'))->first(); - - if ($user && $user->failed_logins >= MAX_FAILED_LOGINS) { - Session::flash('error', trans('texts.invalid_credentials')); - return redirect()->to('login'); - } - - $response = self::postLogin($request); - - if (Auth::check()) { - Event::fire(new UserLoggedIn()); - - $users = false; - // we're linking a new account - if ($request->link_accounts && $userId && Auth::user()->id != $userId) { - $users = $this->accountRepo->associateAccounts($userId, Auth::user()->id); - Session::flash('message', trans('texts.associated_accounts')); - // check if other accounts are linked - } else { - $users = $this->accountRepo->loadAccounts(Auth::user()->id); - } - Session::put(SESSION_USER_ACCOUNTS, $users); - - } elseif ($user) { - $user->failed_logins = $user->failed_logins + 1; - $user->save(); - } - - return $response; - } - - - public function getLogoutWrapper() - { - if (Auth::check() && !Auth::user()->registered) { - $account = Auth::user()->account; - $this->accountRepo->unlinkAccount($account); - $account->forceDelete(); - } - - $response = self::getLogout(); - - Session::flush(); - - return $response; - } -} diff --git a/app/Http/Controllers/ClientAuth/AuthController.php b/app/Http/Controllers/ClientAuth/AuthController.php new file mode 100644 index 0000000000..079b95984e --- /dev/null +++ b/app/Http/Controllers/ClientAuth/AuthController.php @@ -0,0 +1,62 @@ +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..1c67f41ffd --- /dev/null +++ b/app/Http/Controllers/ClientAuth/PasswordController.php @@ -0,0 +1,168 @@ +middleware('guest'); + Config::set("auth.defaults.passwords","client"); + } + + public function showLinkRequestForm() + { + return view('clientauth.password'); + } + + /** + * 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(); + } + + return view('clientauth.reset')->with(compact('token', 'invitation_key')); + } + + + + /** + * 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/Authenticate.php b/app/Http/Middleware/Authenticate.php index 1780b299f0..08097d6a9b 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,45 @@ 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'){ + + if(Auth::guard('user')->check()){ + // This is an admin; let them pretend to be a client + $authenticated = true; + } + + // Does this account require portal passwords? + $invitation_key = session('invitation_key'); + $account = Account::whereId($this->getInvitationAccountId($invitation_key))->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 +61,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/routes.php b/app/Http/routes.php index 1ec65eb34d..3e784670ae 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -35,7 +35,7 @@ Route::get('/keep_alive', 'HomeController@keepAlive'); Route::post('/get_started', 'AccountController@getStarted'); // Client visible pages -Route::group(['middleware' => 'auth'], function() { +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'); @@ -78,6 +78,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'); @@ -89,7 +98,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'); @@ -685,4 +694,4 @@ if (Utils::isNinjaDev()) //ini_set('memory_limit','1024M'); //Auth::loginUsingId(1); } -*/ \ No newline at end of file +*/ diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php deleted file mode 100644 index 569762b389..0000000000 --- a/app/Providers/AuthServiceProvider.php +++ /dev/null @@ -1,46 +0,0 @@ -app->alias('customerauth', 'App\Auth\CustomerAuthManager'); - $this->app->alias('customerauth.driver', 'App\Auth\SiteGuard'); - $this->app->alias('customerauth.driver', 'App\Contracts\Auth\SiteGuard'); - - parent::register(); - } - - protected function registerAuthenticator() - { - $this->app->singleton('customerauth', function ($app) { - $app['customerauth.loaded'] = true; - - return new CustomerAuthManager($app); - }); - - $this->app->singleton('customerauth.driver', function ($app) { - return $app['customerauth']->driver(); - }); - } - - protected function registerUserResolver() - { - $this->app->bind('Illuminate\Contracts\Auth\Authenticatable', function ($app) { - return $app['customerauth']->user(); - }); - } - - protected function registerRequestRebindHandler() - { - $this->app->rebinding('request', function ($app, $request) { - $request->setUserResolver(function() use ($app) { - return $app['customerauth']->user(); - }); - }); - } -} \ No newline at end of file diff --git a/config/auth.php b/config/auth.php index dd7606cc53..7b08fd4731 100644 --- a/config/auth.php +++ b/config/auth.php @@ -13,7 +13,7 @@ return [ */ 'defaults' => [ - 'guard' => 'web', + 'guard' => 'user', 'passwords' => 'users', ], @@ -35,10 +35,15 @@ return [ */ 'guards' => [ - 'web' => [ + 'user' => [ 'driver' => 'session', 'provider' => 'users', ], + + 'client' => [ + 'driver' => 'session', + 'provider' => 'client', + ], 'api' => [ 'driver' => 'token', @@ -68,11 +73,11 @@ return [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + + 'client' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Contact::class, + ] ], /* @@ -97,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/resources/views/client.blade.php b/resources/views/client.blade.php index 2dd313456b..dcf7223463 100644 --- a/resources/views/client.blade.php +++ b/resources/views/client.blade.php @@ -32,6 +32,9 @@ {!! Former::text('last_name')->data_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..d21165eda2 --- /dev/null +++ b/resources/views/clientauth/login.blade.php @@ -0,0 +1,116 @@ +@extends('master') + +@section('head') + + + + + + +@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..a4f6396757 --- /dev/null +++ b/resources/views/clientauth/password.blade.php @@ -0,0 +1,101 @@ +@extends('master') + +@section('head') + + + + + + +@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..93dd6d7a69 --- /dev/null +++ b/resources/views/clientauth/reset.blade.php @@ -0,0 +1,104 @@ +@extends('master') + +@section('head') + + + + + + +@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 4419ca45d0..dd4b282734 100644 --- a/resources/views/clients/edit.blade.php +++ b/resources/views/clients/edit.blade.php @@ -93,7 +93,7 @@ attr: {name: 'contacts[' + \$index() + '][email]', id:'email'+\$index()}") !!} {!! Former::text('phone')->data_bind("value: phone, valueUpdate: 'afterkeydown', attr: {name: 'contacts[' + \$index() + '][phone]'}") !!} - @if (!Utils::isPro() || $account->enable_portal_password) + @if ($account->isPro() && $account->enable_portal_password) {!! Former::password('password')->data_bind("value: password()?'-%unchanged%-':'', valueUpdate: 'afterkeydown', attr: {name: 'contacts[' + \$index() + '][password]'}") !!} @endif 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 652a0d365f..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]'}") !!} -f + @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
    From d0d30e1e26d5b7f85b4509094f94e8f90d1cffbe Mon Sep 17 00:00:00 2001 From: Joshua Dwire Date: Sat, 5 Mar 2016 18:09:50 -0500 Subject: [PATCH 3/6] Add support for including password in email; include portal customizations --- app/Http/Controllers/AccountController.php | 3 +- .../Controllers/ClientAuth/AuthController.php | 19 +++++++++- .../ClientAuth/PasswordController.php | 35 +++++++++++++++++-- app/Ninja/Mailers/ContactMailer.php | 31 ++++++++++++++++ .../2016_02_25_152948_add_client_password.php | 2 -- resources/lang/en/texts.php | 6 ++-- .../views/accounts/client_portal.blade.php | 10 ++---- resources/views/clientauth/login.blade.php | 22 +++++++----- resources/views/clientauth/password.blade.php | 21 +++++++---- resources/views/clientauth/reset.blade.php | 21 +++++++---- 10 files changed, 129 insertions(+), 41 deletions(-) diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index c555052752..b64d8b9884 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -431,7 +431,6 @@ class AccountController extends BaseController $data = [ 'client_view_css' => $css, 'enable_portal_password' => $account->enable_portal_password, - 'fill_portal_password' => $account->fill_portal_password, 'send_portal_password' => $account->send_portal_password, 'title' => trans("texts.client_portal"), 'section' => ACCOUNT_CLIENT_PORTAL, @@ -549,8 +548,8 @@ class AccountController extends BaseController $account = Auth::user()->account; $account->client_view_css = $sanitized_css; + $account->enable_client_portal = !!Input::get('enable_client_portal'); $account->enable_portal_password = !!Input::get('enable_portal_password'); - $account->fill_portal_password = !!Input::get('fill_portal_password'); $account->send_portal_password = !!Input::get('send_portal_password'); $account->save(); diff --git a/app/Http/Controllers/ClientAuth/AuthController.php b/app/Http/Controllers/ClientAuth/AuthController.php index 079b95984e..c88c8a4b85 100644 --- a/app/Http/Controllers/ClientAuth/AuthController.php +++ b/app/Http/Controllers/ClientAuth/AuthController.php @@ -22,7 +22,24 @@ class AuthController extends Controller { public function showLoginForm() { - return view('clientauth.login'); + $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.login')->with($data); } /** diff --git a/app/Http/Controllers/ClientAuth/PasswordController.php b/app/Http/Controllers/ClientAuth/PasswordController.php index 1c67f41ffd..beefb01612 100644 --- a/app/Http/Controllers/ClientAuth/PasswordController.php +++ b/app/Http/Controllers/ClientAuth/PasswordController.php @@ -2,7 +2,6 @@ use Config; use App\Http\Controllers\Controller; -use App\Http\Brokers\ClientPasswordBroker; use Illuminate\Foundation\Auth\ResetsPasswords; use Illuminate\Http\Request; use Illuminate\Mail\Message; @@ -42,7 +41,22 @@ class PasswordController extends Controller { public function showLinkRequestForm() { - return view('clientauth.password'); + $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); } /** @@ -93,8 +107,23 @@ class PasswordController extends Controller { 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(compact('token', 'invitation_key')); + return view('clientauth.reset')->with($data); } diff --git a/app/Ninja/Mailers/ContactMailer.php b/app/Ninja/Mailers/ContactMailer.php index 87e3c82c02..ce3c06a77b 100644 --- a/app/Ninja/Mailers/ContactMailer.php +++ b/app/Ninja/Mailers/ContactMailer.php @@ -31,6 +31,7 @@ class ContactMailer extends Mailer 'viewButton', 'paymentLink', 'paymentButton', + 'password', ]; public function sendInvoice(Invoice $invoice, $reminder = false, $pdfString = false) @@ -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) { @@ -253,6 +283,7 @@ class ContactMailer extends Mailer '$customClient2' => $account->custom_client_label2, '$customInvoice1' => $account->custom_invoice_text_label1, '$customInvoice2' => $account->custom_invoice_text_label2, + '$password' => isset($data['password'])?$data['password']:false, ]; // Add variables for available payment types diff --git a/database/migrations/2016_02_25_152948_add_client_password.php b/database/migrations/2016_02_25_152948_add_client_password.php index f19f70b729..49fcae7fcb 100644 --- a/database/migrations/2016_02_25_152948_add_client_password.php +++ b/database/migrations/2016_02_25_152948_add_client_password.php @@ -14,7 +14,6 @@ class AddClientPassword extends Migration { { Schema::table('accounts', function ($table) { $table->boolean('enable_portal_password')->default(0); - $table->boolean('fill_portal_password')->default(0); $table->boolean('send_portal_password')->default(0); }); @@ -34,7 +33,6 @@ class AddClientPassword extends Migration { { Schema::table('accounts', function ($table) { $table->dropColumn('enable_portal_password'); - $table->dropColumn('fill_portal_password'); $table->dropColumn('send_portal_password'); }); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index c85a1fc9fa..1e83a7821b 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1053,10 +1053,10 @@ $LANG = array( 'enable_client_portal_help' => 'Show/hide the dashboard page in the client portal.', // Client Passwords - 'client_portal_login_settings'=>'Login', - 'enable_portal_password'=>'Require a password', + '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', - 'fill_portal_password'=>'Include password in invoice emails', + '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 6892061d4b..daa145d6b8 100644 --- a/resources/views/accounts/client_portal.blade.php +++ b/resources/views/accounts/client_portal.blade.php @@ -15,6 +15,7 @@ {!! Former::populateField('enable_client_portal', intval($account->enable_client_portal)) !!} {!! Former::populateField('client_view_css', $client_view_css) !!} {!! Former::populateField('enable_portal_password', $enable_portal_password) !!} +{!! Former::populateField('send_portal_password', $send_portal_password) !!} @if (!Utils::isNinja() && !Auth::user()->account->isWhiteLabel())
    @@ -40,16 +41,13 @@
    {!! Former::checkbox('enable_portal_password') ->text(trans('texts.enable_portal_password')) - ->label(' ') !!} -
    -
    - {!! Former::checkbox('fill_portal_password') - ->text(trans('texts.fill_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(' ') !!}
    @@ -67,7 +65,6 @@ ->autofocus() ->maxlength(60000) ->style("min-width:100%;max-width:100%;font-family:'Roboto Mono', 'Lucida Console', Monaco, monospace;font-size:14px;'") !!} -
    @@ -82,7 +79,6 @@ $('#enable_portal_password').change(fixCheckboxes); function fixCheckboxes(){ var checked = $('#enable_portal_password').is(':checked'); - $('#fill_portal_password').prop('disabled', !checked); $('#send_portal_password').prop('disabled', !checked); } fixCheckboxes(); diff --git a/resources/views/clientauth/login.blade.php b/resources/views/clientauth/login.blade.php index d21165eda2..0469e882b0 100644 --- a/resources/views/clientauth/login.blade.php +++ b/resources/views/clientauth/login.blade.php @@ -1,10 +1,7 @@ -@extends('master') +@extends('public.header') @section('head') - - - - +@parent