From f93bfad3ce2c19ee3ce76e500cd79bf6ca96a62a Mon Sep 17 00:00:00 2001 From: hackerESQ Date: Mon, 21 Oct 2024 22:23:20 -0500 Subject: [PATCH] feat:adds ability to share portfolios also includes basic permissions and authorization --- app/Imports/ValidatesPortfolioPermissions.php | 6 +- app/Models/Portfolio.php | 28 +-- app/Models/User.php | 2 +- app/Policies/PortfolioPolicy.php | 40 ++++ ...30_112537_create_portfolio_users_table.php | 3 +- ...024_10_21_155635_update_shared_columns.php | 32 +++ lang/en.json | 18 +- lang/es.json | 18 +- .../views/components/ib-drawer.blade.php | 2 +- resources/views/components/ib-form.blade.php | 2 +- .../views/components/section-border.blade.php | 2 +- resources/views/holding/show.blade.php | 16 +- .../livewire/manage-portfolio-form.blade.php | 15 +- .../manage-transaction-form.blade.php | 5 +- .../livewire/share-portfolio-form.blade.php | 208 ++++++++++++++++++ .../livewire/transactions-list.blade.php | 8 + .../livewire/transactions-table.blade.php | 7 - resources/views/portfolio/show.blade.php | 4 + 18 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 app/Policies/PortfolioPolicy.php create mode 100644 database/migrations/2024_10_21_155635_update_shared_columns.php create mode 100644 resources/views/livewire/share-portfolio-form.blade.php diff --git a/app/Imports/ValidatesPortfolioPermissions.php b/app/Imports/ValidatesPortfolioPermissions.php index d982d77..ece5f4a 100644 --- a/app/Imports/ValidatesPortfolioPermissions.php +++ b/app/Imports/ValidatesPortfolioPermissions.php @@ -3,6 +3,7 @@ namespace App\Imports; use Exception; +use App\Models\Portfolio; trait ValidatesPortfolioPermissions { @@ -12,7 +13,10 @@ trait ValidatesPortfolioPermissions { $collection->pluck('portfolio_id')->unique()->each(function($portfolio) use ($portfolios) { - if (!$portfolios->contains($portfolio)) { + if ( + !$portfolios->contains($portfolio) + || auth()->user()->cannot('fullAccess', Portfolio::find($portfolio)) + ) { throw new Exception('You do not have permission to access that portfolio.'); } diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php index 9b62abf..1a931e3 100644 --- a/app/Models/Portfolio.php +++ b/app/Models/Portfolio.php @@ -26,9 +26,9 @@ class Portfolio extends Model { parent::boot(); - static::saved(function ($model) { + static::saved(function ($portfolio) { - self::syncUsers($model); + self::ensurePortfolioHasOwner($portfolio); }); } @@ -42,7 +42,7 @@ class Portfolio extends Model public function users() { - return $this->belongsToMany(User::class)->withPivot('owner'); + return $this->belongsToMany(User::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']); } public function holdings() @@ -76,21 +76,23 @@ class Portfolio extends Model public function getOwnerIdAttribute() { - return $this->users()->firstWhere('owner', 1)?->id; + return $this->owner?->id; } - public static function syncUsers(self $model) + public function getOwnerAttribute() + { + return $this->users()->firstWhere('owner', 1); + } + + public static function ensurePortfolioHasOwner(self $portfolio) { // make sure we don't remove owner access - $user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true]; + if (!$portfolio->owner_id) { + $users[auth()->user()->id] = ['owner' => true]; - // // add other users - // foreach(request()->users ?? [] as $id) { - // $user_id[$id] = ['owner' => false]; - // }; - - // save - $model->users()->sync($user_id); + // save + $portfolio->users()->sync($users); + } } public function syncDailyChanges(): void diff --git a/app/Models/User.php b/app/Models/User.php index ee14950..eaf85d7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -52,7 +52,7 @@ class User extends Authenticatable implements MustVerifyEmail public function portfolios() { - return $this->belongsToMany(Portfolio::class)->withPivot('owner'); + return $this->belongsToMany(Portfolio::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']); } public function daily_changes() diff --git a/app/Policies/PortfolioPolicy.php b/app/Policies/PortfolioPolicy.php new file mode 100644 index 0000000..e4e44c8 --- /dev/null +++ b/app/Policies/PortfolioPolicy.php @@ -0,0 +1,40 @@ +users()->where('user_id', $user->id)->first(); + + return $pivot; + } + + /** + * + */ + public function fullAccess(User $user, Portfolio $portfolio) + { + $pivot = $portfolio->users()->where('user_id', $user->id)->first(); + + return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner); + } + + /** + * + */ + public function owner(User $user, Portfolio $portfolio) + { + $pivot = $portfolio->users()->where('user_id', $user->id)->first(); + + return $pivot && $pivot->pivot->owner; + } +} diff --git a/database/migrations/2021_01_30_112537_create_portfolio_users_table.php b/database/migrations/2021_01_30_112537_create_portfolio_users_table.php index dfb028c..6205c04 100644 --- a/database/migrations/2021_01_30_112537_create_portfolio_users_table.php +++ b/database/migrations/2021_01_30_112537_create_portfolio_users_table.php @@ -19,7 +19,8 @@ return new class extends Migration $table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade'); $table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade'); $table->boolean('owner')->default(false); - $table->boolean('write')->default(false); + $table->boolean('full_access')->default(false); + $table->datetime('invite_accepted_at')->nullable(); $table->primary(['portfolio_id', 'user_id']); }); } diff --git a/database/migrations/2024_10_21_155635_update_shared_columns.php b/database/migrations/2024_10_21_155635_update_shared_columns.php new file mode 100644 index 0000000..3597cb6 --- /dev/null +++ b/database/migrations/2024_10_21_155635_update_shared_columns.php @@ -0,0 +1,32 @@ +renameColumn('write', 'full_access'); + $table->datetime('invite_accepted_at')->nullable(); + }); + + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + + + } +}; \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index e97406a..2cac179 100644 --- a/lang/en.json +++ b/lang/en.json @@ -18,6 +18,7 @@ "or": "or", "and": "and", "Yes": "Yes", + "you": "you", "Nothing to show here yet": "Nothing to show here yet", "Hang on! You're doing that too much.": "Hang on! You're doing that too much.", @@ -334,5 +335,20 @@ "auth.failed": "These credentials do not match our records.", "auth.password": "The provided password is incorrect.", - "auth.throttle": "Too many login attempts. Please try again in :seconds seconds." + "auth.throttle": "Too many login attempts. Please try again in :seconds seconds.", + + "Add people": "Add people", + "People with access": "People with access", + "Owner": "Owner", + "Read only": "Read only", + "Full access": "Full access", + "You do not have permission to manage transactions for this portfolio": "You do not have permission to manage transactions for this portfolio", + "Updated user's access permission to portfolio": "Updated user's access permission to portfolio", + "Removed user's access to portfolio": "Removed user's access to portfolio", + "Shared portfolio with user": "Shared portfolio with user", + "Share Portfolio": "Share Portfolio", + "Type an email address to share portfolio": "Type an email address to share portfolio", + "Grant full access": "Grant full access", + "Allow this user to manage portfolio details and create or update transactions": "Allow this user to manage portfolio details and create or update transactions", + "Share": "Share" } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 25300ac..160c2da 100644 --- a/lang/es.json +++ b/lang/es.json @@ -18,6 +18,7 @@ "or": "o", "and": "y", "Yes": "Sí", + "you": "tú", "Nothing to show here yet": "No hay nada que mostrar aquí todavía", "Hang on! You're doing that too much.": "¡Por favor espere un momento!", @@ -334,5 +335,20 @@ "auth.failed": "Estas credenciales no coinciden con nuestros registros.", "auth.password": "La contraseña es incorrecta.", - "auth.throttle": "Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos." + "auth.throttle": "Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos.", + + "Add people": "Agregar personas", + "People with access": "Personas con acceso", + "Read only": "Sólo lectura", + "Full access": "Acceso completo", + "Owner": "Dueño", + "You do not have permission to manage transactions for this portfolio": "No tienes permisos para administrar transacciones", + "Updated user's access permission to portfolio": "Se actualizó el permiso de acceso del usuario al portafolio", + "Removed user's access to portfolio": "Se eliminó el acceso del usuario al portafolio", + "Shared portfolio with user": "Portafolio compartido con usuario", + "Share Portfolio": "Compartir portafolio", + "Type an email address to share portfolio": "Escribe una dirección de correo electrónico para compartir portafolio", + "Grant full access": "Otorgar acceso completo", + "Allow this user to manage portfolio details and create or update transactions": "Permitir a este usuario administrar detalles de portafolio y crear o actualizar transacciones", + "Share": "Compartir" } \ No newline at end of file diff --git a/resources/views/components/ib-drawer.blade.php b/resources/views/components/ib-drawer.blade.php index b943e4a..4b58853 100644 --- a/resources/views/components/ib-drawer.blade.php +++ b/resources/views/components/ib-drawer.blade.php @@ -24,7 +24,7 @@ merge(['class' => 'min-h-screen w-11/12 lg:w-1/3 rounded-none px-8 transition']) }} + {{ $attributes->merge(['class' => 'min-h-screen w-5/6 xl:w-3/5 rounded-none px-8 transition']) }} > @if($title) diff --git a/resources/views/components/ib-form.blade.php b/resources/views/components/ib-form.blade.php index b2cb633..4db7082 100644 --- a/resources/views/components/ib-form.blade.php +++ b/resources/views/components/ib-form.blade.php @@ -11,7 +11,7 @@ @if ($actions) @if(!$noSeparator) -
+ @endif
diff --git a/resources/views/components/section-border.blade.php b/resources/views/components/section-border.blade.php index 917a283..9512464 100644 --- a/resources/views/components/section-border.blade.php +++ b/resources/views/components/section-border.blade.php @@ -1,5 +1,5 @@ -
class(['py-6' => !$attributes->has('class'), 'h-4 sm:h-auto' => $attributes->has('hide-on-mobile')]) }}> +
class(['my-6' => !$attributes->has('class'), 'h-4 sm:h-auto' => $attributes->has('hide-on-mobile')]) }}>
\ No newline at end of file diff --git a/resources/views/holding/show.blade.php b/resources/views/holding/show.blade.php index 71dbcd5..7579859 100644 --- a/resources/views/holding/show.blade.php +++ b/resources/views/holding/show.blade.php @@ -30,22 +30,24 @@ + @can('fullAccess', $portfolio) + @endcan -
- -
+ @can('fullAccess', $portfolio) + + @endcan
diff --git a/resources/views/livewire/manage-portfolio-form.blade.php b/resources/views/livewire/manage-portfolio-form.blade.php index eb92a7d..f7fed92 100644 --- a/resources/views/livewire/manage-portfolio-form.blade.php +++ b/resources/views/livewire/manage-portfolio-form.blade.php @@ -27,7 +27,6 @@ new class extends Component { // methods public function mount() { - if (isset($this->portfolio)) { $this->title = $this->portfolio->title; @@ -38,8 +37,9 @@ new class extends Component { public function update() { + $this->authorize('fullAccess', $this->portfolio); + $this->portfolio->update($this->validate()); - // $this->portfolio->owner_id = auth()->user()->id; $this->portfolio->save(); $this->success(__('Portfolio updated'), redirectTo: "/portfolio/{$this->portfolio->id}"); @@ -47,8 +47,10 @@ new class extends Component { public function save() { + $this->authorize('fullAccess', $this->portfolio); + $portfolio = (new Portfolio())->fill($this->validate()); - // $portfolio->owner_id = auth()->user()->id; + $portfolio->save(); $this->success(__('Portfolio created'), redirectTo: "/portfolio/{$portfolio->id}"); @@ -56,6 +58,7 @@ new class extends Component { public function delete() { + $this->authorize('fullAccess', $this->portfolio); $this->portfolio->delete(); @@ -67,9 +70,11 @@ new class extends Component { - + - + @livewire('share-portfolio-form', ['portfolio' => $portfolio]) + + {{ __('Treat this portfolio as a "wishlist" (holdings will be excluded from realized gains, unrealized gains, and dividends)') }} diff --git a/resources/views/livewire/manage-transaction-form.blade.php b/resources/views/livewire/manage-transaction-form.blade.php index b48de2b..c034efb 100644 --- a/resources/views/livewire/manage-transaction-form.blade.php +++ b/resources/views/livewire/manage-transaction-form.blade.php @@ -30,7 +30,6 @@ new class extends Component { // methods public function rules() { - return [ 'symbol' => ['required', 'string', new SymbolValidationRule], 'transaction_type' => 'required|string|in:BUY,SELL', @@ -68,6 +67,7 @@ new class extends Component { public function update() { + $this->authorize('fullAccess', $this->portfolio); $this->transaction->update($this->validate()); // $this->transaction->owner_id = auth()->user()->id; @@ -81,6 +81,8 @@ new class extends Component { public function save() { + $this->authorize('fullAccess', $this->portfolio); + $validated = $this->validate(); if (!isset($this->portfolio)) { @@ -97,6 +99,7 @@ new class extends Component { public function delete() { + $this->authorize('fullAccess', $this->portfolio); $this->transaction->delete(); diff --git a/resources/views/livewire/share-portfolio-form.blade.php b/resources/views/livewire/share-portfolio-form.blade.php new file mode 100644 index 0000000..624fd41 --- /dev/null +++ b/resources/views/livewire/share-portfolio-form.blade.php @@ -0,0 +1,208 @@ +portfolio) { + $this->permissions = [ + auth()->user()->id => [ + 'owner' => true, + 'full_access' => false + ] + ]; + + } else { + + $this->permissions = collect($this->portfolio->users)->mapWithKeys(function ($user) { + return [ + $user->id => [ + 'owner' => $user->pivot->owner ?? 0, + 'full_access' => $user->pivot->full_access ?? 0 + ] + ]; + })->toArray(); + } + } + + public function updatedPermissions() + { + $this->authorize('fullAccess', $this->portfolio); + + $this->portfolio->users()->sync($this->permissions); + + $this->portfolio->refresh(); + + $this->success(__('Updated user\'s access permission to portfolio')); + } + + public function deleteUser(string $userId) + { + $this->authorize('fullAccess', $this->portfolio); + + unset($this->permissions[$userId]); + + $this->portfolio->users()->sync($this->permissions); + + $this->portfolio->refresh(); + + $this->success(__('Removed user\'s access to portfolio')); + } + + public function addUser() + { + $this->authorize('fullAccess', $this->portfolio); + + $this->validate(); + + $user = User::firstOrCreate([ + 'email' => $this->emailAddress + ], [ + 'name' => Str::title(Str::before($this->emailAddress, '@')) + ]); + + $this->permissions[$user->id] = [ + 'full_access' => $this->fullAccess + ]; + + $this->portfolio->users()->sync($this->permissions); + + $this->success(__('Shared portfolio with user')); + $this->portfolio->refresh(); + + $this->dispatch('toggle-add-user-modal'); + + $this->emailAddress = ''; + $this->fullAccess = false; + } + +}; ?> + +
+ @if ($this->portfolio) + + + +
+ @php + $owner = collect($this->portfolio?->users)->where('pivot.owner', 1)->first() ?? auth()->user(); + @endphp + + + + {{ $owner->name }} + + @if (auth()->user()->id == $owner->id) + ({{ __('you') }}) + @endif + + + {{ __('Owner') }} + + + + @foreach (collect($this->portfolio?->users)->where('pivot.owner', '!=', 1) as $user) + + + {{ $user->email }} + + + + + + + + + + @endforeach + + +
+ + + + + + + + + + + + +
+ +
+ + + {{ __('Add people') }} + + +
+ @endif +
\ No newline at end of file diff --git a/resources/views/livewire/transactions-list.blade.php b/resources/views/livewire/transactions-list.blade.php index c2f353c..04d38e4 100644 --- a/resources/views/livewire/transactions-list.blade.php +++ b/resources/views/livewire/transactions-list.blade.php @@ -4,9 +4,12 @@ use App\Models\Portfolio; use App\Models\Transaction; use Illuminate\Support\Collection; use Livewire\Volt\Component; +use Mary\Traits\Toast; new class extends Component { + use Toast; + // props public Collection $transactions; public ?Portfolio $portfolio; @@ -21,6 +24,11 @@ new class extends Component { // methods public function showTransactionDialog($transactionId) { + if (!auth()->user()->can('fullAccess', $this->portfolio)) { + $this->error(__('You do not have permission to manage transactions for this portfolio')); + return; + } + $this->editingTransaction = Transaction::findOrFail($transactionId); $this->dispatch('toggle-manage-transaction'); } diff --git a/resources/views/livewire/transactions-table.blade.php b/resources/views/livewire/transactions-table.blade.php index 9dadc6a..ebb039c 100644 --- a/resources/views/livewire/transactions-table.blade.php +++ b/resources/views/livewire/transactions-table.blade.php @@ -29,12 +29,6 @@ new class extends Component { return $this->redirect(route('holding.show', ['portfolio' => $holding['portfolio_id'], 'symbol' => $holding['symbol']])); } - // public function showTransactionDialog($transactionId) - // { - // $this->editingTransaction = Transaction::findOrFail($transactionId); - // $this->dispatch('toggle-manage-transaction'); - // } - public function mount() { $this->headers = [ @@ -69,7 +63,6 @@ new class extends Component { x-data="{ loadingId: null, timeout: null }" @row-click=" timeout = setTimeout(() => { loadingId = $event.detail.id }, 200); - {{-- $wire.showTransactionDialog($event.detail.id).then(() => { --}} $wire.goToHolding($event.detail).then(() => { clearTimeout(timeout); loadingId = null; diff --git a/resources/views/portfolio/show.blade.php b/resources/views/portfolio/show.blade.php index 8bc4cef..d6a4cea 100644 --- a/resources/views/portfolio/show.blade.php +++ b/resources/views/portfolio/show.blade.php @@ -28,15 +28,18 @@ @endif + @can('fullAccess', $portfolio) + @endcan + @can('fullAccess', $portfolio)
+ @endcan @livewire('portfolio-performance-chart', [