feat(permissions): viewers review drafts in a read-only editor; lock /accounts to admins
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.
This commit is contained in:
parent
a5ddbcf84f
commit
1c9ab462d0
9 changed files with 93 additions and 61 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const currentWorkspace = computed<Workspace | null>(() => page.props.auth.curren
|
|||
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
|
||||
const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subscriptionPastDue));
|
||||
|
||||
const { canCreatePost, canManageAutomations, canCreateWorkspace } = useWorkspaceRole();
|
||||
const { canCreatePost, canManageAccounts, canManageAutomations, canCreateWorkspace } = useWorkspaceRole();
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
|
|
@ -114,11 +114,15 @@ const postsNavItems = computed<NavItem[]>(() => [
|
|||
]);
|
||||
|
||||
const workspaceNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
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 = () => {
|
|||
|
||||
<NavMain v-if="currentWorkspace" :items="mainNavItems" />
|
||||
<NavMain v-if="currentWorkspace" :items="postsNavItems" :label="$t('sidebar.groups.posts')" />
|
||||
<NavMain v-if="currentWorkspace" :items="workspaceNavItems" :label="$t('sidebar.groups.workspace')" />
|
||||
<NavMain v-if="currentWorkspace && workspaceNavItems.length" :items="workspaceNavItems" :label="$t('sidebar.groups.workspace')" />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
|
|
|||
|
|
@ -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<InstanceType<typeof ConfirmDeleteModal> | null>(
|
|||
null,
|
||||
);
|
||||
|
||||
const { canManageAccounts } = useWorkspaceRole();
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup(() => {
|
||||
router.reload();
|
||||
});
|
||||
|
|
@ -309,33 +306,31 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="canManageAccounts">
|
||||
<Button
|
||||
v-if="cardState[platform.value] === CardState.Reconnect"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="reconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.reconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="cardState[platform.value] === CardState.Connected"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="disconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="connectPlatform(platform.value)"
|
||||
>
|
||||
{{ $t('accounts.connect_cta') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-if="cardState[platform.value] === CardState.Reconnect"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="reconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.reconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="cardState[platform.value] === CardState.Connected"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="disconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="connectPlatform(platform.value)"
|
||||
>
|
||||
{{ $t('accounts.connect_cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
canEdit: true,
|
||||
});
|
||||
|
||||
const hasPickedTime = defineModel<boolean>('hasPickedTime', { required: true });
|
||||
const scheduledDateTime = defineModel<string>('scheduledDateTime', { required: true });
|
||||
|
|
@ -61,6 +64,7 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
|
|||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="bg-background hover:bg-violet-50"
|
||||
|
|
@ -91,7 +95,7 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isReadOnly" class="flex flex-col items-end gap-1">
|
||||
<div v-if="!isReadOnly && canEdit" class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ const getStatusColor = (status: string): string => {
|
|||
const EDITABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled];
|
||||
|
||||
const getPostUrl = (post: Post): string => {
|
||||
return canCreatePost.value && EDITABLE_STATUSES.includes(post.status)
|
||||
return EDITABLE_STATUSES.includes(post.status)
|
||||
? editPost.url(post.id)
|
||||
: showPost.url(post.id);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
getMediaIncompatibilityReason,
|
||||
usePostCompliance,
|
||||
} from '@/composables/usePostCompliance';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import date from '@/date';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
|
|
@ -90,6 +91,8 @@ const props = defineProps<{
|
|||
authUserId: string;
|
||||
}>();
|
||||
|
||||
const { canCreatePost } = useWorkspaceRole();
|
||||
|
||||
const post = computed(() => props.post);
|
||||
const READONLY_STATUSES: readonly string[] = [
|
||||
PostStatus.Publishing,
|
||||
|
|
@ -100,9 +103,9 @@ 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. Field edits and auto-save suppressed;
|
||||
// user must unschedule to re-enter draft and edit.
|
||||
const isLocked = computed(() => isReadOnly.value || isScheduled.value);
|
||||
// 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
|
||||
const content = ref(post.value.content || '');
|
||||
|
|
@ -201,7 +204,10 @@ const isPostActionDisabled = computed(
|
|||
const queryParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const initialTabFromQuery = (() => {
|
||||
const tab = queryParams?.get('tab');
|
||||
return ['preview', 'schedule', 'comments'].includes(tab ?? '') ? (tab as string) : 'schedule';
|
||||
if (['preview', 'schedule', 'comments'].includes(tab ?? '')) {
|
||||
return tab as string;
|
||||
}
|
||||
return canCreatePost.value ? 'schedule' : 'comments';
|
||||
})();
|
||||
const initialHighlightCommentId = queryParams?.get('comment') ?? null;
|
||||
const activeTab = ref(initialTabFromQuery);
|
||||
|
|
@ -352,6 +358,7 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<PostEditorHeader
|
||||
:post="post"
|
||||
:can-edit="canCreatePost"
|
||||
:is-saving="isSaving"
|
||||
:show-saved="showSaved"
|
||||
:is-submitting="isSubmitting"
|
||||
|
|
@ -387,7 +394,11 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
class="flex h-full"
|
||||
:class="{ 'pointer-events-none select-none opacity-60': isScheduled }"
|
||||
>
|
||||
<div class="w-full overflow-y-auto lg:w-2/3 lg:border-r-2 lg:border-foreground">
|
||||
<div
|
||||
class="w-full overflow-y-auto lg:w-2/3 lg:border-r-2 lg:border-foreground"
|
||||
:class="{ 'pointer-events-none select-none opacity-60': !canCreatePost }"
|
||||
:inert="!canCreatePost"
|
||||
>
|
||||
<PostEditorComposer
|
||||
v-model:content="content"
|
||||
v-model:media="media"
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ const canDelete = (post: Post): boolean => DELETABLE_STATUSES.includes(post.stat
|
|||
const { canCreatePost } = useWorkspaceRole();
|
||||
|
||||
const postUrl = (post: Post): string =>
|
||||
canCreatePost.value && canEdit(post) ? editPost.url(post.id) : showPost.url(post.id);
|
||||
canEdit(post) ? editPost.url(post.id) : showPost.url(post.id);
|
||||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
|
|
@ -27,6 +28,12 @@
|
|||
]);
|
||||
$this->workspace->members()->attach($this->member->id, ['role' => Role::Member->value]);
|
||||
|
||||
$this->admin = User::factory()->create([
|
||||
'account_id' => $this->owner->account_id,
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->admin->id, ['role' => Role::Admin->value]);
|
||||
|
||||
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
|
|
@ -61,20 +68,30 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('a viewer opening a draft post sees the read-only page instead of the editor', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
test('opening a draft post redirects to the editor for every workspace member', function (string $actor) {
|
||||
$this->actingAs($this->{$actor})
|
||||
->get(route('app.posts.show', $this->post))
|
||||
->assertRedirect(route('app.posts.edit', $this->post));
|
||||
})->with(['admin', 'member', 'viewer']);
|
||||
|
||||
test('a viewer can open the post editor to review and comment', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
->get(route('app.posts.edit', $this->post))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('a member opening a draft post is redirected to the editor', function () {
|
||||
$this->actingAs($this->member)
|
||||
->get(route('app.posts.show', $this->post))
|
||||
->assertRedirect(route('app.posts.edit', $this->post));
|
||||
});
|
||||
|
||||
test('a viewer cannot open the post editor directly', function () {
|
||||
test('a viewer cannot save changes to a post', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
->get(route('app.posts.edit', $this->post))
|
||||
->put(route('app.posts.update', $this->post), ['status' => Status::Draft->value])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('only admins and above can open the connections screen', function (string $actor, bool $allowed) {
|
||||
$response = $this->actingAs($this->{$actor})->get(route('app.accounts'));
|
||||
|
||||
$allowed ? $response->assertOk() : $response->assertForbidden();
|
||||
})->with([
|
||||
'admin' => ['admin', true],
|
||||
'member' => ['member', false],
|
||||
'viewer' => ['viewer', false],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue