Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions config/visual-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,20 @@
|
*/
'site_editor' => [
'route_prefix' => 'site-editor',
'middleware' => [ 'web', 'auth' ],
'permission' => 'visual-editor.access-site-editor',
'hub_page' => ArtisanPackUI\VisualEditor\Livewire\SiteEditor\HubPage::class,
'layout' => ArtisanPackUI\VisualEditor\View\Components\SiteEditorLayout::class,
'route_prefix' => 'site-editor',
'middleware' => [ 'web', 'auth' ],
'permission' => 'visual-editor.access-site-editor',
'hub_page' => ArtisanPackUI\VisualEditor\Livewire\SiteEditor\HubPage::class,
'layout' => ArtisanPackUI\VisualEditor\View\Components\SiteEditorLayout::class,
'gates' => [
'access' => 'visual-editor.access-site-editor',
'styles' => 'visual-editor.manage-styles',
'templates' => 'visual-editor.manage-templates',
'parts' => 'visual-editor.manage-parts',
'patterns' => 'visual-editor.manage-patterns',
'template_styles' => 'visual-editor.manage-template-styles',
'lock_content' => 'visual-editor.lock-content',
],
],

'blocks' => [
Expand Down
16 changes: 16 additions & 0 deletions resources/lang/en/ve.php
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,22 @@
'hub_access_denied' => 'You do not have permission to access the Site Editor.',
'hub_back' => 'Back to Site Editor',

// Permissions
'permission_access_site_editor' => 'Access Site Editor',
'permission_access_site_editor_desc' => 'View the site editor hub.',
'permission_manage_styles' => 'Manage Global Styles',
'permission_manage_styles_desc' => 'Edit global styles (colors, typography, spacing).',
'permission_manage_templates' => 'Manage Templates',
'permission_manage_templates_desc' => 'Create, edit, and delete templates.',
'permission_manage_parts' => 'Manage Template Parts',
'permission_manage_parts_desc' => 'Create, edit, and delete template parts.',
'permission_manage_patterns' => 'Manage Patterns',
'permission_manage_patterns_desc' => 'Create, edit, and delete patterns.',
'permission_manage_template_styles' => 'Manage Template Styles',
'permission_manage_template_styles_desc' => 'Edit template-level style overrides.',
'permission_lock_content' => 'Lock Content',
'permission_lock_content_desc' => 'Lock and unlock templates and template parts.',

// Global Styles Page
'global_styles_title' => 'Global Styles',
'global_styles_save' => 'Save',
Expand Down
10 changes: 8 additions & 2 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,40 @@

declare( strict_types=1 );

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;

$prefix = (string) config( 'artisanpack.visual-editor.site_editor.route_prefix', 'site-editor' );
$middleware = (array) config( 'artisanpack.visual-editor.site_editor.middleware', [ 'web', 'auth' ] );
$permission = (string) config( 'artisanpack.visual-editor.site_editor.permission', 'visual-editor.access-site-editor' );
$gates = (array) config( 'artisanpack.visual-editor.site_editor.gates', [] );
$hubPage = (string) config( 'artisanpack.visual-editor.site_editor.hub_page', \ArtisanPackUI\VisualEditor\Livewire\SiteEditor\HubPage::class );

$prefix = veApplyFilters( 've.site-editor.route-prefix', $prefix );

if ( '' !== $permission ) {
$middleware[] = 'can:' . $permission;
$middleware[] = 've.gate:' . $permission;
}

Route::prefix( $prefix )
->middleware( $middleware )
->group( function () use ( $hubPage ): void {
->group( function () use ( $hubPage, $gates ): void {
Route::get( '/', $hubPage )
->name( 'visual-editor.site-editor' );

Route::get( '/global-styles', \ArtisanPackUI\VisualEditor\Livewire\SiteEditor\GlobalStylesPage::class )
->middleware( veGateMiddleware( $gates['styles'] ?? '' ) )
->name( 'visual-editor.global-styles' );

Route::get( '/templates', \ArtisanPackUI\VisualEditor\Livewire\SiteEditor\TemplateListingPage::class )
->middleware( veGateMiddleware( $gates['templates'] ?? '' ) )
->name( 'visual-editor.templates' );

Route::get( '/parts', \ArtisanPackUI\VisualEditor\Livewire\SiteEditor\TemplatePartListingPage::class )
->middleware( veGateMiddleware( $gates['parts'] ?? '' ) )
->name( 'visual-editor.template-parts' );

Route::get( '/patterns', \ArtisanPackUI\VisualEditor\Livewire\SiteEditor\PatternListingPage::class )
->middleware( veGateMiddleware( $gates['patterns'] ?? '' ) )
->name( 'visual-editor.patterns' );
} );
59 changes: 59 additions & 0 deletions src/Http/Middleware/CheckGateIfDefined.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/**
* Check Gate If Defined Middleware.
*
* Only performs an authorization check when the gate has been
* explicitly registered. When the gate is not defined (e.g. the
* cms-framework is not installed), the request passes through
* without any authorization check (graceful degradation).
*
* @package ArtisanPack_UI
* @subpackage VisualEditor\Http\Middleware
*
* @author Jacob Martella <me@jacobmartella.com>
*
* @since 1.0.0
*/

declare( strict_types=1 );

namespace ArtisanPackUI\VisualEditor\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;

/**
* Middleware that checks an authorization gate only when it is defined.
*
* @package ArtisanPack_UI
* @subpackage VisualEditor\Http\Middleware
*
* @since 1.0.0
*/
class CheckGateIfDefined
{
/**
* Handle an incoming request.
*
* @since 1.0.0
*
* @param Request $request The incoming request.
* @param Closure $next The next middleware.
* @param string $ability The gate ability to check.
*
* @return Response
*/
public function handle( Request $request, Closure $next, string $ability ): Response
{
if ( Gate::has( $ability ) ) {
if ( ! $request->user() || ! $request->user()->can( $ability ) ) {
abort( 403 );
}
}

return $next( $request );
}
}
49 changes: 48 additions & 1 deletion src/Livewire/SiteEditor/HubPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

namespace ArtisanPackUI\VisualEditor\Livewire\SiteEditor;

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
Expand Down Expand Up @@ -67,6 +69,8 @@ public function getCards(): array
{
$prefix = (string) config( 'artisanpack.visual-editor.site_editor.route_prefix', 'site-editor' );

$gates = (array) config( 'artisanpack.visual-editor.site_editor.gates', [] );

Comment thread
coderabbitai[bot] marked this conversation as resolved.
$cards = [
[
'slug' => 'global-styles',
Expand All @@ -75,6 +79,7 @@ public function getCards(): array
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /></svg>',
'url' => url( $prefix . '/global-styles' ),
'count' => null,
'permission' => $gates['styles'] ?? null,
],
[
'slug' => 'templates',
Expand All @@ -83,6 +88,7 @@ public function getCards(): array
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>',
'url' => url( $prefix . '/templates' ),
'count' => $this->getTemplateCount(),
'permission' => $gates['templates'] ?? null,
],
[
'slug' => 'template-parts',
Expand All @@ -91,6 +97,7 @@ public function getCards(): array
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 0 1-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 0 0-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 0 1-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 0 0 .657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 0 1-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 0 0 5.427-.63 48.05 48.05 0 0 0 .582-4.717.532.532 0 0 0-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 0 0 .657-.663 48.422 48.422 0 0 0-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 0 1-.61-.58v0Z" /></svg>',
'url' => url( $prefix . '/parts' ),
'count' => $this->getTemplatePartCount(),
'permission' => $gates['parts'] ?? null,
],
[
'slug' => 'patterns',
Expand All @@ -99,10 +106,13 @@ public function getCards(): array
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /></svg>',
'url' => url( $prefix . '/patterns' ),
'count' => null,
'permission' => $gates['patterns'] ?? null,
],
];

return veApplyFilters( 've.hub.cards', $cards );
$cards = veApplyFilters( 've.hub.cards', $cards );

return $this->filterCardsByPermission( $cards );
}

/**
Expand All @@ -119,6 +129,43 @@ public function render(): View
] );
}

/**
* Filter hub cards by the current user's permissions.
*
* Cards without a permission key or with a null permission are always shown.
* Cards with a permission are only shown if the gate is not defined
* (graceful degradation) or if the user passes the gate check.
*
* @since 1.0.0
*
* @param array<int, array<string, mixed>> $cards The hub cards to filter.
*
* @return array<int, array<string, mixed>>
*/
protected function filterCardsByPermission( array $cards ): array
{
/** @var Authenticatable|null $user */
$user = Auth::user();

return array_values( array_filter( $cards, function ( array $card ) use ( $user ): bool {
$permission = $card['permission'] ?? null;

if ( null === $permission || '' === $permission ) {
return true;
}

if ( ! Gate::has( $permission ) ) {
return true;
}

if ( null === $user ) {
return false;
}

return $user->can( $permission );
} ) );
Comment on lines +154 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Laravel's authorization system, does Gate::has('ability') return true for abilities handled only via Gate::before callbacks (for example Spatie Permission or Bouncer), or only for abilities explicitly registered with Gate::define / policies?

💡 Result:

Gate::has('ability') returns true only for abilities that exist in the Gate’s internal $abilities map (i.e., abilities registered via Gate::define() / Gate::resource() / etc.). It does not consider Gate::before() callbacks (and it also won’t “discover” policy methods) when deciding whether an ability “has been defined”. [1]

So if an ability is handled only by a Gate::before callback (common in “dynamic permission” setups), Gate::has('that-ability') will be false unless something also calls Gate::define('that-ability', ...) (or equivalent) for it. [1] Gate::before() affects authorization evaluation, not whether the ability is registered/defined. [2]

Spatie’s docs, for example, show doing permission checks via Gate::before(...), which means Gate::has() won’t reflect those abilities unless you separately define them. [3]

Sources
[1] Laravel framework Gate::has() implementation (checks only $this->abilities[...]) (github.com)
[2] Laravel Authorization docs (Gate::before runs before other checks; its return value determines the check result) (laravel.com)
[3] Spatie laravel-permission docs showing permission logic via Gate::before(...) (spatie.be)

Citations:


🏁 Script executed:

# Verify the file exists and check the specific lines
if [ -f "src/Livewire/SiteEditor/HubPage.php" ]; then
  echo "=== File exists. Checking lines 145-166 ==="
  sed -n '145,166p' "src/Livewire/SiteEditor/HubPage.php"
else
  echo "File not found at src/Livewire/SiteEditor/HubPage.php"
  # Try to locate it
  find . -name "HubPage.php" -type f 2>/dev/null | head -5
fi

Repository: ArtisanPack-UI/visual-editor

Length of output: 627


🏁 Script executed:

# Check the referenced middleware file
if [ -f "src/Http/Middleware/CheckGateIfDefined.php" ]; then
  echo "=== CheckGateIfDefined.php exists. Checking lines 35-58 ==="
  sed -n '35,58p' "src/Http/Middleware/CheckGateIfDefined.php"
else
  echo "File not found at src/Http/Middleware/CheckGateIfDefined.php"
  # Try to locate it
  find . -name "CheckGateIfDefined.php" -type f 2>/dev/null | head -5
fi

Repository: ArtisanPack-UI/visual-editor

Length of output: 659


🏁 Script executed:

# Search for tests related to filterCardsByPermission
find . -name "*.php" -path "*/test*" -o -name "*Test.php" | xargs grep -l "filterCardsByPermission" 2>/dev/null || echo "No tests found for filterCardsByPermission"

Repository: ArtisanPack-UI/visual-editor

Length of output: 114


🏁 Script executed:

# Search for Spatie Permission setup in tests
find . -name "*.php" -path "*/test*" | xargs grep -l "spatie.*permission\|Spatie.*Permission" 2>/dev/null | head -10 || echo "No Spatie Permission tests found"

Repository: ArtisanPack-UI/visual-editor

Length of output: 54


🏁 Script executed:

# Check what permission systems are actually tested
grep -r "Gate::define\|Gate::before" --include="*.php" src/ | head -20

Repository: ArtisanPack-UI/visual-editor

Length of output: 54


🏁 Script executed:

# Search for tests related to HubPage or CheckGateIfDefined
find . -name "*.php" -type f | xargs grep -l "HubPage\|CheckGateIfDefined" 2>/dev/null | grep -i test

Repository: ArtisanPack-UI/visual-editor

Length of output: 170


🏁 Script executed:

# Check if there are gate definitions in test setup/bootstrap
find . -path "*/tests/*" -name "*.php" -type f | xargs grep -l "Gate::" 2>/dev/null | head -10

Repository: ArtisanPack-UI/visual-editor

Length of output: 621


🏁 Script executed:

# Check HubPageTest.php - see what tests exist for filterCardsByPermission
cat -n ./tests/Feature/Livewire/HubPageTest.php

Repository: ArtisanPack-UI/visual-editor

Length of output: 6136


🏁 Script executed:

# Check GracefulDegradationTest.php
cat -n ./tests/Feature/Authorization/GracefulDegradationTest.php

Repository: ArtisanPack-UI/visual-editor

Length of output: 5661


🏁 Script executed:

# Check SiteEditorAuthorizationTest.php
wc -l ./tests/Feature/Authorization/SiteEditorAuthorizationTest.php && head -100 ./tests/Feature/Authorization/SiteEditorAuthorizationTest.php

Repository: ArtisanPack-UI/visual-editor

Length of output: 2815


Gate::has() skips authorization when using permission packages like Spatie Permission.

Lines 157 and 51 (middleware) both treat Gate::has($permission) returning false as "allow access", but Gate::has() only returns true for abilities explicitly registered via Gate::define() / policies. Permission packages like Spatie Permission and Bouncer handle authorization via Gate::before() callbacks without defining abilities, causing Gate::has() to return false. This allows unauthorized users to see cards and access routes.

The test suite confirms this: GracefulDegradationTest.php (lines 71–87) explicitly verifies that cards are shown and routes are accessible when gates are configured but not registered. All existing tests use only Gate::define(), so the Spatie Permission scenario (authorization via Gate::before() only) is never exercised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Livewire/SiteEditor/HubPage.php` around lines 145 - 166, The current
filterCardsByPermission uses Gate::has($permission) to short-circuit and allow
access when Gate::has returns false, which skips authorization hooks from
packages like Spatie; instead, remove the Gate::has() branch and evaluate the
permission against the actual user context (use
Gate::forUser($user)->allows($permission) or, when $user is not null,
$user->can($permission)); ensure filterCardsByPermission returns false if $user
is null and a permission is required, otherwise call
Gate::forUser($user)->allows($permission) (or $user->can($permission)) to decide
visibility.

}

/**
* Get the count of registered templates.
*
Expand Down
64 changes: 64 additions & 0 deletions src/Policies/GlobalStylePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/**
* Global Style Policy.
*
* Provides authorization checks for global style management
* within the site editor. Uses applyFilters for extensibility.
*
* @package ArtisanPack_UI
* @subpackage VisualEditor\Policies
*
* @author Jacob Martella <me@jacobmartella.com>
*
* @since 1.0.0
*/

declare( strict_types=1 );

namespace ArtisanPackUI\VisualEditor\Policies;

use Illuminate\Contracts\Auth\Authenticatable;

/**
* Policy for global style operations in the site editor.
*
* @package ArtisanPack_UI
* @subpackage VisualEditor\Policies
*
* @since 1.0.0
*/
class GlobalStylePolicy
{
/**
* Determine whether the user can update global styles.
*
* @since 1.0.0
*
* @param Authenticatable $user The authenticated user.
*
* @return bool
*/
public function update( Authenticatable $user ): bool
{
$permission = veApplyFilters( 've.global-style.update', 'visual-editor.manage-styles' );

return $user->can( $permission );
}

/**
* Determine whether the user can reset global styles to defaults.
*
* @since 1.0.0
*
* @param Authenticatable $user The authenticated user.
*
* @return bool
*/
public function reset( Authenticatable $user ): bool
{
$permission = veApplyFilters( 've.global-style.reset', 'visual-editor.manage-styles' );

return $user->can( $permission );
}
}
Loading
Loading