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);