1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-05 18:52:44 +01:00

Password protected invoices (#3635)

* Password protected invoices (wip)

* Add support for invitations

* Update comments & php-cs-fixer

* Add Forgot your password
This commit is contained in:
Benjamin Beganović 2020-04-16 23:19:21 +02:00 committed by GitHub
parent 01e8afc1f6
commit 2215f40ec3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 209 additions and 1 deletions

View File

@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\ClientPortal;
use App\Models\Invoice;
use App\Http\Controllers\Controller;
use App\Utils\Traits\MakesHash;
use Illuminate\Support\Facades\Hash;
class EntityViewController extends Controller
{
use MakesHash;
/**
* Available options for viewing.
*
* @var array
*/
private $entity_types = ['invoice', 'quote'];
/**
* Show the entity outside client portal.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function index(string $entity_type, string $invitation_key)
{
if (!in_array($entity_type, $this->entity_types)) {
abort(404);
}
$invitation_entity = sprintf('App\\Models\\%sInvitation', ucfirst($entity_type));
$key = $entity_type . '_id';
$invitation = $invitation_entity::whereRaw("BINARY `key`= ?", [$invitation_key])->firstOrFail();
$contact = $invitation->contact;
if (is_null($contact->password) || empty($contact->password)) {
return redirect("/client/password/reset?email={$contact->email}");
}
$entity_class = sprintf('App\\Models\\%s', ucfirst($entity_type));
$entity = $entity_class::findOrFail($invitation->{$key});
if ((bool) $invitation->contact->client->getSetting('enable_client_portal_password') !== false) {
session()->flash("{$entity_type}_VIEW_{$entity->hashed_id}", true);
}
if (!session("{$entity_type}_VIEW_{$entity->hashed_id}")) {
return redirect()->route('client.entity_view.password', compact('entity_type', 'invitation_key'));
}
return $this->render('view_entity.index', [
'root' => 'themes',
'entity' => $entity,
]);
}
/**
* Show the form for entering password.
*
* @param string $entity_type
* @param string $invitation_key
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function password(string $entity_type, string $invitation_key)
{
return $this->render('view_entity.password', [
'root' => 'themes',
'entity_type' => $entity_type,
]);
}
/**`
* Handle the password check.
*
* @param string $entity_type
* @param string $invitation_key
*
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse|mixed
*/
public function handlePassword(string $entity_type, string $invitation_key)
{
if (!in_array($entity_type, $this->entity_types)) {
abort(404);
}
$invitation_entity = sprintf('App\\Models\\%sInvitation', ucfirst($entity_type));
$key = $entity_type . '_id';
$invitation = $invitation_entity::whereRaw("BINARY `key`= ?", [$invitation_key])->firstOrFail();
$contact = $invitation->contact;
$check = Hash::check(request()->password, $contact->password);
$entity_class = sprintf('App\\Models\\%s', ucfirst($entity_type));
$entity = $entity_class::findOrFail($invitation->{$key});
if ($check) {
session()->flash("{$entity_type}_VIEW_{$entity->hashed_id}", true);
return redirect()->route('client.entity_view', compact('entity_type', 'invitation_key'));
}
session()->flash('PASSWORD_FAILED', true);
return back();
}
}

View File

@ -3201,4 +3201,5 @@ return [
'page' => 'Page',
'of' => 'Of',
'view_credit' => 'View Credit',
'to_view_entity_password' => 'To view the :entity you need to enter password.',
];

View File

@ -24,7 +24,7 @@
<label for="email" class="input-label">{{ ctrans('texts.email_address') }}</label>
<input type="email" name="email" id="email"
class="input"
value="{{ old('email') }}"
value="{{ request()->query('email') ?? old('email') }}"
autofocus>
@error('email')
<div class="validation validation-fail">

View File

@ -0,0 +1,58 @@
@extends('portal.ninja2020.layout.clean')
@push('head')
<meta name="pdf-url" content="{{ asset($entity->pdf_file_path()) }}">
<script src="{{ asset('js/vendor/pdf.js/pdf.min.js') }}"></script>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script>
@endpush
@section('body')
<div class="container mx-auto my-10">
<div class="flex items-center justify-between">
<section class="flex items-center">
<button class="input-label" id="previous-page-button">
<svg class="w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button class="input-label" id="next-page-button">
<svg class="w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</section>
<div class="flex items-center">
@if($entity instanceof App\Models\Invoice)
<button class="button button-primary">{{ ctrans('texts.pay_now') }}</button>
@elseif($$entity instanceof App\Models\Quote)
<button class="button button-primary">{{ ctrans('texts.approve') }}</button>
@endif
<button class="button button-primary ml-2">{{ ctrans('texts.download') }}</button>
<div x-data="{ open: false }" @keydown.escape="open = false" @click.away="open = false" class="relative inline-block text-left ml-2">
<div>
<button @click="open = !open" class="flex items-center text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</div>
<div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg">
<div class="rounded-md bg-white shadow-xs">
<div class="py-1">
<a target="_blank" href="{{ asset($entity->pdf_file_path()) }}" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900">{{ ctrans('texts.open_in_new_tab') }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center">
<canvas id="pdf-placeholder" class="shadow-lg border rounded-lg bg-white mt-4 p-4"></canvas>
</div>
</div>
@endsection
@section('footer')
<script src="{{ asset('js/clients/shared/pdf.js') }}"></script>
@endsection

View File

@ -0,0 +1,31 @@
@extends('portal.ninja2020.layout.clean')
@section('body')
<div class="flex h-screen">
<div class="m-auto md:w-1/3 lg:w-1/5">
<div class="flex flex-col">
<h1 class="text-center text-3xl">{{ ctrans('texts.password') }}</h1>
<p class="text-sm text-center text-gray-700">{{ ctrans('texts.to_view_entity_password', ['entity' => $entity_type]) }}</p>
<form method="post" class="mt-6">
@csrf
<div class="flex flex-col">
<div class="flex justify-between items-center">
<label for="password" class="input-label">{{ ctrans('texts.password') }}</label>
<a class="text-xs text-gray-600 hover:text-gray-800 ease-in duration-100" href="{{ route('client.password.request') }}">{{ trans('texts.forgot_password') }}</a>
</div>
<input type="password" name="password" id="password" class="input" autofocus>
@if(session('PASSWORD_FAILED'))
<div class="validation validation-fail">
{{ ctrans('auth.failed') }}
</div>
@endif
</div>
<div class="mt-5">
<button class="button button-primary button-block">{{ ctrans('texts.continue') }}</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -12,6 +12,10 @@ Route::post('client/password/email', 'Auth\ContactForgotPasswordController@sendR
Route::get('client/password/reset/{token}', 'Auth\ContactResetPasswordController@showResetForm')->name('client.password.reset')->middleware('locale');
Route::post('client/password/reset', 'Auth\ContactResetPasswordController@reset')->name('client.password.update')->middleware('locale');
Route::get('view/{entity_type}/{invitation_key}', 'ClientPortal\EntityViewController@index')->name('client.entity_view');
Route::get('view/{entity_type}/{invitation_key}/password', 'ClientPortal\EntityViewController@password')->name('client.entity_view.password');
Route::post('view/{entity_type}/{invitation_key}/password', 'ClientPortal\EntityViewController@handlePassword');
//todo implement domain DB
Route::group(['middleware' => ['auth:contact','locale'], 'prefix' => 'client', 'as' => 'client.'], function () {
Route::get('dashboard', 'ClientPortal\DashboardController@index')->name('dashboard'); // name = (dashboard. index / create / show / update / destroy / edit