From dc29d4dd488e717b838f4e16c2d821da72a5e0b9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 18:00:36 -0300 Subject: [PATCH] fix(permissions): address code-review findings - store(): members without connected accounts no longer get redirected into the now-admin-only /accounts (403); non-managers go to the calendar with the same flash, admins still go to /accounts - cover the SyncPostPlatforms can('update') gate (viewer creates no platform rows; member does) and the store redirect split, in WorkspaceRolePermissions - cover PostPolicy::duplicate viewer-denied - docs sidebar link uses the canonical https://docs.trypost.it - drop orphaned sidebar.support.{discord,last_updates} keys in all locales - remove the explanatory isLocked comment in Edit.vue --- app/Http/Controllers/App/PostController.php | 4 +- lang/en/sidebar.php | 2 - lang/es/sidebar.php | 2 - lang/pt-BR/sidebar.php | 2 - resources/js/components/AppSidebar.vue | 2 +- resources/js/pages/posts/Edit.vue | 2 - .../WorkspaceRolePermissionsTest.php | 39 +++++++++++++++++++ tests/Unit/Policies/PostPolicyTest.php | 3 +- 8 files changed, 45 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index eca05277..a573f8da 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -180,7 +180,9 @@ public function store(StorePostRequest $request): RedirectResponse|\Symfony\Comp session()->flash('flash.banner', __('posts.flash.connect_first')); session()->flash('flash.bannerStyle', 'danger'); - return redirect()->route('app.accounts'); + return $request->user()->can('manageAccounts', $workspace) + ? redirect()->route('app.accounts') + : redirect()->route('app.calendar'); } $post = CreatePost::execute($workspace, $request->user(), [ diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index bd31be23..f9cddbd6 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -54,8 +54,6 @@ 'no_notifications' => 'No notifications', 'support' => [ - 'discord' => 'Discord', - 'last_updates' => 'Last Updates', 'docs' => 'Documentation', 'referral' => 'Earn 30% referral', 'stay_updated' => 'Stay updated', diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index fdebf2c1..c6f768d4 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -54,8 +54,6 @@ 'no_notifications' => 'Sin notificaciones', 'support' => [ - 'discord' => 'Discord', - 'last_updates' => 'Últimas actualizaciones', 'docs' => 'Documentación', 'referral' => 'Gana 30% de comisión', 'stay_updated' => 'Mantente al día', diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index b10d4683..0717d621 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -54,8 +54,6 @@ 'no_notifications' => 'Sem notificações', 'support' => [ - 'discord' => 'Discord', - 'last_updates' => 'Últimas Atualizações', 'docs' => 'Documentação', 'referral' => 'Ganhe 30% de indicação', 'stay_updated' => 'Fique por dentro', diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 16f564ed..d7b66dd7 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -161,7 +161,7 @@ const supportNavItems = computed(() => [ }, { title: trans('sidebar.support.docs'), - href: 'https://trypost.it/docs', + href: 'https://docs.trypost.it', icon: IconLifebuoy, }, ]); diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 245ce6f7..c5a8770f 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -103,8 +103,6 @@ const READONLY_STATUSES: readonly string[] = [ const isReadOnly = computed(() => READONLY_STATUSES.includes(post.value.status)); const isPublishing = computed(() => post.value.status === PostStatus.Publishing); const isScheduled = computed(() => post.value.status === PostStatus.Scheduled); -// Locked states — terminal + scheduled, plus viewers who can only comment. -// Field edits and auto-save are suppressed. const isLocked = computed(() => isReadOnly.value || isScheduled.value || !canCreatePost.value); // Content diff --git a/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php b/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php index 47399923..9fdf7333 100644 --- a/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php +++ b/tests/Feature/Permissions/WorkspaceRolePermissionsTest.php @@ -5,6 +5,7 @@ use App\Enums\Post\Status; use App\Enums\UserWorkspace\Role; use App\Models\Post; +use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; @@ -95,3 +96,41 @@ 'member' => ['member', false], 'viewer' => ['viewer', false], ]); + +test('opening the editor does not create platform rows for a viewer', function () { + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'is_active' => true, + ]); + + $this->actingAs($this->viewer) + ->get(route('app.posts.edit', $this->post)) + ->assertOk(); + + expect($this->post->postPlatforms()->count())->toBe(0); +}); + +test('opening the editor syncs platform rows for a member', function () { + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'is_active' => true, + ]); + + $this->actingAs($this->member) + ->get(route('app.posts.edit', $this->post)) + ->assertOk(); + + expect($this->post->postPlatforms()->count())->toBe(1); +}); + +test('a member without connected accounts is redirected away from the admin-only accounts screen when creating a post', function () { + $this->actingAs($this->member) + ->post(route('app.posts.store')) + ->assertRedirect(route('app.calendar')); +}); + +test('an admin without connected accounts is sent to the accounts screen when creating a post', function () { + $this->actingAs($this->admin) + ->post(route('app.posts.store')) + ->assertRedirect(route('app.accounts')); +}); diff --git a/tests/Unit/Policies/PostPolicyTest.php b/tests/Unit/Policies/PostPolicyTest.php index 60d57390..5a0efd52 100644 --- a/tests/Unit/Policies/PostPolicyTest.php +++ b/tests/Unit/Policies/PostPolicyTest.php @@ -43,11 +43,12 @@ function postPolicyActor(string $role): array expect($this->policy->view($actor, $post))->toBeTrue(); })->with(['owner', 'admin', 'member', 'viewer']); -test('post update/delete is allowed for member+ and denied for viewer', function (string $role, bool $allowed) { +test('post update/delete/duplicate is allowed for member+ and denied for viewer', function (string $role, bool $allowed) { [$actor, $post] = postPolicyActor($role); expect($this->policy->update($actor, $post))->toBe($allowed); expect($this->policy->delete($actor, $post))->toBe($allowed); + expect($this->policy->duplicate($actor, $post))->toBe($allowed); })->with([ 'owner' => ['owner', true], 'admin' => ['admin', true],