From 1c9ab462d05b34e06bc0f595e3bac9625dff2150 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 22 Jun 2026 17:04:09 -0300 Subject: [PATCH] feat(permissions): viewers review drafts in a read-only editor; lock /accounts to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viewers are typically the client: they need to open a draft in the editor to use the comments tab, but must not change anything. - post editor (edit) now authorizes view, so viewers can open it; the composer + schedule tab render read-only and the comments tab stays interactive (defaults to the comments tab for viewers) - all mutations stay member+ (update/delete) — the autosave/save/publish/ schedule/delete affordances are hidden and the PUT is still 403 for viewers; SyncPostPlatforms only runs for users who can update - drafts route to the editor for everyone again (reverts the read-only Show detour); Show stays the published-post view - /accounts now authorizes manageAccounts (admin+), so viewers and members get 403; the Connections sidebar item is admin+ only and the connect/ disconnect grid is reverted to main (no per-button gating needed) Tests: draft→editor redirect for every member, viewer can open the editor, viewer cannot save, and only admins+ can open /accounts. --- app/Http/Controllers/App/PostController.php | 9 +-- .../Controllers/Auth/SocialController.php | 2 +- resources/js/components/AppSidebar.vue | 18 +++--- .../accounts/NetworkConnectGrid.vue | 55 +++++++++---------- .../posts/editor/PostEditorHeader.vue | 8 ++- resources/js/pages/posts/Calendar.vue | 2 +- resources/js/pages/posts/Edit.vue | 21 +++++-- resources/js/pages/posts/Index.vue | 2 +- .../WorkspaceRolePermissionsTest.php | 37 +++++++++---- 9 files changed, 93 insertions(+), 61 deletions(-) diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 5dff497b..eca05277 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -212,8 +212,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse $this->authorize('view', $post); - if ($request->user()->can('update', $post) - && in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) { + if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) { return redirect()->route('app.posts.edit', $post); } @@ -233,13 +232,15 @@ public function edit(Request $request, Post $post): Response|RedirectResponse return redirect()->route('app.workspaces.create'); } - $this->authorize('update', $post); + $this->authorize('view', $post); if (PostStatusRules::blocksEditing($post)) { return redirect()->route('app.posts.show', $post); } - SyncPostPlatforms::execute($post); + if ($request->user()->can('update', $post)) { + SyncPostPlatforms::execute($post); + } $post->load(['postPlatforms.socialAccount', 'labels']); $socialAccounts = $workspace->socialAccounts()->active()->get(); diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index ec9c5009..bd4a68cd 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -41,7 +41,7 @@ public function index(Request $request): Response|RedirectResponse return redirect()->route('app.workspaces.create'); } - $this->authorize('view', $workspace); + $this->authorize('manageAccounts', $workspace); $platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [ 'value' => $platform->value, diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 81f50672..5146f42c 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -64,7 +64,7 @@ const currentWorkspace = computed(() => page.props.auth.curren const workspaces = computed(() => page.props.auth.workspaces as Workspace[]); const subscriptionPastDue = computed(() => Boolean(page.props.auth.subscriptionPastDue)); -const { canCreatePost, canManageAutomations, canCreateWorkspace } = useWorkspaceRole(); +const { canCreatePost, canManageAccounts, canManageAutomations, canCreateWorkspace } = useWorkspaceRole(); const mainNavItems = computed(() => [ { @@ -114,11 +114,15 @@ const postsNavItems = computed(() => [ ]); const workspaceNavItems = computed(() => [ - { - title: trans('sidebar.workspace.connections'), - href: accounts.url(), - icon: IconAffiliate, - }, + ...(canManageAccounts.value + ? [ + { + title: trans('sidebar.workspace.connections'), + href: accounts.url(), + icon: IconAffiliate, + }, + ] + : []), ...(canCreatePost.value ? [ { @@ -212,7 +216,7 @@ const handleCreateWorkspace = () => { - + diff --git a/resources/js/components/accounts/NetworkConnectGrid.vue b/resources/js/components/accounts/NetworkConnectGrid.vue index 92afd97e..9058817b 100644 --- a/resources/js/components/accounts/NetworkConnectGrid.vue +++ b/resources/js/components/accounts/NetworkConnectGrid.vue @@ -8,7 +8,6 @@ import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.v import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'; import { Button } from '@/components/ui/button'; import { useOAuthPopup } from '@/composables/useOAuthPopup'; -import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import { disconnect } from '@/routes/app/accounts'; import { Platform } from '@/types/platform'; @@ -164,8 +163,6 @@ const disconnectModal = ref | null>( null, ); -const { canManageAccounts } = useWorkspaceRole(); - const { openOAuthPopup } = useOAuthPopup(() => { router.reload(); }); @@ -309,33 +306,31 @@ const cardState = computed((): Record => {

- + + + diff --git a/resources/js/components/posts/editor/PostEditorHeader.vue b/resources/js/components/posts/editor/PostEditorHeader.vue index 950c37fe..085df40a 100644 --- a/resources/js/components/posts/editor/PostEditorHeader.vue +++ b/resources/js/components/posts/editor/PostEditorHeader.vue @@ -11,6 +11,7 @@ import { PostStatus } from '@/types/post'; interface Props { post: { status: string }; + canEdit?: boolean; isSaving: boolean; showSaved: boolean; isSubmitting: boolean; @@ -19,7 +20,9 @@ interface Props { pickTimeLabel: string; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + canEdit: true, +}); const hasPickedTime = defineModel('hasPickedTime', { required: true }); const scheduledDateTime = defineModel('scheduledDateTime', { required: true }); @@ -61,6 +64,7 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);