From bf1fa6a9c0f220ed23300d295987ef06131ebd1b Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 21:38:38 +0200 Subject: [PATCH 1/5] feat(plugins): add rating and report data layer Adds PluginRating and PluginReport models with their own migrations, enums, policies, form requests, controllers, and routes. Free plugins can be rated by any logged-in user; paid plugins reuse the existing User::hasPluginAccess() check. Reports are a private admin-only channel (distinct from the existing developer messaging thread) capped at one open report per user per plugin. Also extracts the hardcoded support@nativephp.com address used across 7 notification call sites into config('mail.support_address'). Co-Authored-By: Claude Sonnet 5 --- app/Enums/PluginReportCategory.php | 21 ++++ app/Enums/PluginReportStatus.php | 30 +++++ .../Controllers/PluginRatingController.php | 39 ++++++ .../Controllers/PluginReportController.php | 36 ++++++ .../Requests/StorePluginRatingRequest.php | 30 +++++ .../Requests/StorePluginReportRequest.php | 34 ++++++ app/Livewire/Customer/Plugins/Show.php | 2 +- app/Livewire/Customer/Support/Create.php | 2 +- app/Livewire/Customer/Support/Show.php | 2 +- app/Livewire/ShowcaseSubmissionForm.php | 4 +- app/Livewire/WallOfLoveSubmissionForm.php | 2 +- app/Models/Plugin.php | 43 ++++++- app/Models/PluginRating.php | 66 ++++++++++ app/Models/PluginReport.php | 115 ++++++++++++++++++ app/Models/User.php | 10 ++ app/Notifications/PluginReported.php | 45 +++++++ app/Policies/PluginRatingPolicy.php | 27 ++++ app/Policies/PluginReportPolicy.php | 30 +++++ config/mail.php | 13 ++ ..._04_120000_create_plugin_ratings_table.php | 32 +++++ ...rage_and_rating_count_to_plugins_table.php | 29 +++++ ..._04_120002_create_plugin_reports_table.php | 37 ++++++ routes/web.php | 8 ++ 23 files changed, 650 insertions(+), 7 deletions(-) create mode 100644 app/Enums/PluginReportCategory.php create mode 100644 app/Enums/PluginReportStatus.php create mode 100644 app/Http/Controllers/PluginRatingController.php create mode 100644 app/Http/Controllers/PluginReportController.php create mode 100644 app/Http/Requests/StorePluginRatingRequest.php create mode 100644 app/Http/Requests/StorePluginReportRequest.php create mode 100644 app/Models/PluginRating.php create mode 100644 app/Models/PluginReport.php create mode 100644 app/Notifications/PluginReported.php create mode 100644 app/Policies/PluginRatingPolicy.php create mode 100644 app/Policies/PluginReportPolicy.php create mode 100644 database/migrations/2026_09_04_120000_create_plugin_ratings_table.php create mode 100644 database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php create mode 100644 database/migrations/2026_09_04_120002_create_plugin_reports_table.php diff --git a/app/Enums/PluginReportCategory.php b/app/Enums/PluginReportCategory.php new file mode 100644 index 000000000..a4ad032e6 --- /dev/null +++ b/app/Enums/PluginReportCategory.php @@ -0,0 +1,21 @@ + 'Malicious or Unsafe Code', + self::UnresponsiveAuthor => 'Unresponsive Author', + self::Other => 'Other', + }; + } +} diff --git a/app/Enums/PluginReportStatus.php b/app/Enums/PluginReportStatus.php new file mode 100644 index 000000000..bf7ee7ebf --- /dev/null +++ b/app/Enums/PluginReportStatus.php @@ -0,0 +1,30 @@ + 'Open', + self::Resolved => 'Resolved', + self::Dismissed => 'Dismissed', + }; + } + + public function color(): string + { + return match ($this) { + self::Open => 'danger', + self::Resolved => 'success', + self::Dismissed => 'gray', + }; + } +} diff --git a/app/Http/Controllers/PluginRatingController.php b/app/Http/Controllers/PluginRatingController.php new file mode 100644 index 000000000..47027408e --- /dev/null +++ b/app/Http/Controllers/PluginRatingController.php @@ -0,0 +1,39 @@ +authorize('create', [PluginRating::class, $plugin]); + + PluginRating::submit($plugin, $request->user(), (int) $request->validated('rating')); + + return back()->with('success', 'Thanks for rating this plugin!'); + } + + public function destroy(Request $request, string $vendor, string $package): RedirectResponse + { + $plugin = Plugin::findByVendorPackageOrFail($vendor, $package); + $rating = PluginRating::findFor($plugin, $request->user()); + + abort_if(! $rating, 404); + + $this->authorize('delete', $rating); + + $rating->delete(); + + return back()->with('success', 'Your rating has been removed.'); + } +} diff --git a/app/Http/Controllers/PluginReportController.php b/app/Http/Controllers/PluginReportController.php new file mode 100644 index 000000000..311f9cfbf --- /dev/null +++ b/app/Http/Controllers/PluginReportController.php @@ -0,0 +1,36 @@ +authorize('create', [PluginReport::class, $plugin]); + + $user = $request->user(); + + if (PluginReport::hasOpenFor($plugin, $user)) { + return back()->with('error', 'You already have an open report for this plugin — our team will follow up soon.'); + } + + PluginReport::file( + $plugin, + $user, + PluginReportCategory::from($request->validated('category')), + $request->validated('message') + ); + + return back()->with('success', 'Thanks — your report has been sent to the NativePHP team.'); + } +} diff --git a/app/Http/Requests/StorePluginRatingRequest.php b/app/Http/Requests/StorePluginRatingRequest.php new file mode 100644 index 000000000..6b473fe7b --- /dev/null +++ b/app/Http/Requests/StorePluginRatingRequest.php @@ -0,0 +1,30 @@ + ['required', 'integer', 'between:1,5'], + ]; + } + + public function messages(): array + { + return [ + 'rating.required' => 'Please select a star rating.', + 'rating.between' => 'Ratings must be between 1 and 5 stars.', + ]; + } +} diff --git a/app/Http/Requests/StorePluginReportRequest.php b/app/Http/Requests/StorePluginReportRequest.php new file mode 100644 index 000000000..19204f5ac --- /dev/null +++ b/app/Http/Requests/StorePluginReportRequest.php @@ -0,0 +1,34 @@ + ['required', Rule::enum(PluginReportCategory::class)], + 'message' => ['required', 'string', 'max:5000'], + ]; + } + + public function messages(): array + { + return [ + 'category.required' => 'Please select a reason for this report.', + 'message.required' => 'Please describe the issue.', + 'message.max' => 'The message cannot be longer than 5000 characters.', + ]; + } +} diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 12f365c65..1fd425d4a 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -204,7 +204,7 @@ public function submitForReview(): void // Notify $user->notify(new PluginSubmitted($this->plugin)); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new PluginPendingReview($this->plugin)); Flux::toast(variant: 'success', text: 'Your plugin has been submitted for review!'); diff --git a/app/Livewire/Customer/Support/Create.php b/app/Livewire/Customer/Support/Create.php index 2ad91a01f..19141e93d 100644 --- a/app/Livewire/Customer/Support/Create.php +++ b/app/Livewire/Customer/Support/Create.php @@ -164,7 +164,7 @@ public function submit(): void $ticket->update(['attachments' => $attachments]); } - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new SupportTicketSubmitted($ticket)); $this->redirect(route('customer.support.tickets.show', $ticket), navigate: false); diff --git a/app/Livewire/Customer/Support/Show.php b/app/Livewire/Customer/Support/Show.php index 8e6337e23..570baaaff 100644 --- a/app/Livewire/Customer/Support/Show.php +++ b/app/Livewire/Customer/Support/Show.php @@ -81,7 +81,7 @@ public function reply(): void 'attachments' => $attachments, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new SupportTicketUserReplied($this->supportTicket, $reply)); $this->replyMessage = ''; diff --git a/app/Livewire/ShowcaseSubmissionForm.php b/app/Livewire/ShowcaseSubmissionForm.php index 454da2c71..eb5a8661b 100644 --- a/app/Livewire/ShowcaseSubmissionForm.php +++ b/app/Livewire/ShowcaseSubmissionForm.php @@ -169,7 +169,7 @@ public function submit(): mixed 'approved_by' => null, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new ShowcaseSubmitted($this->showcase, resubmitted: true)); return to_route('customer.showcase.index') @@ -185,7 +185,7 @@ public function submit(): mixed ...$data, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new ShowcaseSubmitted($showcase)); return to_route('customer.showcase.index') diff --git a/app/Livewire/WallOfLoveSubmissionForm.php b/app/Livewire/WallOfLoveSubmissionForm.php index f4348a9af..cc311b72d 100644 --- a/app/Livewire/WallOfLoveSubmissionForm.php +++ b/app/Livewire/WallOfLoveSubmissionForm.php @@ -103,7 +103,7 @@ public function submit(): mixed 'testimonial' => $this->testimonial ?: null, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new WallOfLoveSubmitted($submission)); return to_route('dashboard')->with('success', 'Thank you! Your submission has been received and is awaiting review.'); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 3a0178f6e..bbb9dd1ee 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -241,6 +241,22 @@ public function licenses(): HasMany return $this->hasMany(PluginLicense::class); } + /** + * @return HasMany + */ + public function ratings(): HasMany + { + return $this->hasMany(PluginRating::class); + } + + /** + * @return HasMany + */ + public function reports(): HasMany + { + return $this->hasMany(PluginReport::class); + } + /** * @return BelongsToMany */ @@ -677,6 +693,21 @@ public function getRepositoryOwnerAndName(): ?array ]; } + public function getIssuesUrl(): ?string + { + if (! $this->repository_url || ! str_contains($this->repository_url, 'github.com')) { + return null; + } + + $repoInfo = $this->getRepositoryOwnerAndName(); + + if (! $repoInfo) { + return null; + } + + return "https://github.com/{$repoInfo['owner']}/{$repoInfo['repo']}/issues"; + } + public function approve(int $approvedById): void { $previousStatus = $this->status; @@ -854,12 +885,20 @@ public function messageAdmins(string $message, ?int $causerId = null): PluginAct 'causer_id' => $causerId ?? $this->user_id, ]); - Notification::route('mail', 'support@nativephp.com') + Notification::route('mail', config('mail.support_address')) ->notify(new PluginDeveloperReplied($this, $activity)); return $activity; } + public function recalculateRating(): void + { + $this->update([ + 'rating_count' => $this->ratings()->count(), + 'rating_average' => $this->ratings()->avg('rating'), + ]); + } + public function updateDescription(string $description, int $updatedById): void { $oldDescription = $this->description; @@ -911,6 +950,8 @@ protected function casts(): array 'webhook_installed' => 'boolean', 'review_checks' => 'array', 'reviewed_at' => 'datetime', + 'rating_average' => 'decimal:2', + 'rating_count' => 'integer', ]; } } diff --git a/app/Models/PluginRating.php b/app/Models/PluginRating.php new file mode 100644 index 000000000..da1b5752f --- /dev/null +++ b/app/Models/PluginRating.php @@ -0,0 +1,66 @@ +plugin->recalculateRating(); + }); + + self::deleted(function (PluginRating $rating): void { + $rating->plugin->recalculateRating(); + }); + } + + public static function findFor(Plugin $plugin, User $user): ?self + { + return self::query() + ->where('plugin_id', $plugin->id) + ->where('user_id', $user->id) + ->first(); + } + + public static function submit(Plugin $plugin, User $user, int $rating): self + { + return self::query()->updateOrCreate( + ['plugin_id' => $plugin->id, 'user_id' => $user->id], + ['rating' => $rating] + ); + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'rating' => 'integer', + ]; + } +} diff --git a/app/Models/PluginReport.php b/app/Models/PluginReport.php new file mode 100644 index 000000000..31e7a2a89 --- /dev/null +++ b/app/Models/PluginReport.php @@ -0,0 +1,115 @@ + $query + * @return Builder + */ + #[Scope] + protected function open(Builder $query): Builder + { + return $query->where('status', PluginReportStatus::Open); + } + + public static function hasOpenFor(Plugin $plugin, User $user): bool + { + return self::query() + ->where('plugin_id', $plugin->id) + ->where('user_id', $user->id) + ->open() + ->exists(); + } + + public static function file(Plugin $plugin, User $user, PluginReportCategory $category, string $message): self + { + $report = self::query()->create([ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'category' => $category, + 'message' => $message, + 'status' => PluginReportStatus::Open, + ]); + + Notification::route('mail', config('mail.support_address')) + ->notify(new PluginReported($report)); + + return $report; + } + + /** + * @return BelongsTo + */ + public function plugin(): BelongsTo + { + return $this->belongsTo(Plugin::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + public function isOpen(): bool + { + return $this->status === PluginReportStatus::Open; + } + + public function resolve(User $admin, ?string $note = null): void + { + $this->update([ + 'status' => PluginReportStatus::Resolved, + 'resolution_note' => $note, + 'resolved_by' => $admin->id, + 'resolved_at' => now(), + ]); + } + + public function dismiss(User $admin, ?string $note = null): void + { + $this->update([ + 'status' => PluginReportStatus::Dismissed, + 'resolution_note' => $note, + 'resolved_by' => $admin->id, + 'resolved_at' => now(), + ]); + } + + protected function casts(): array + { + return [ + 'category' => PluginReportCategory::class, + 'status' => PluginReportStatus::class, + 'resolved_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index dd144d681..0e401fde6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -476,6 +476,16 @@ public function hasPluginAccess(Plugin $plugin): bool return false; } + public function canRatePlugin(Plugin $plugin): bool + { + return $plugin->user_id !== $this->id && $this->hasPluginAccess($plugin); + } + + public function canReportPlugin(Plugin $plugin): bool + { + return $plugin->user_id !== $this->id; + } + public function getGitHubToken(): ?string { if (! $this->github_token) { diff --git a/app/Notifications/PluginReported.php b/app/Notifications/PluginReported.php new file mode 100644 index 000000000..3cb9b64e5 --- /dev/null +++ b/app/Notifications/PluginReported.php @@ -0,0 +1,45 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $report = $this->report->loadMissing(['plugin', 'user']); + + return (new MailMessage) + ->subject('Plugin Reported: '.$report->plugin->name) + ->greeting('A plugin has been reported.') + ->line("**Plugin:** {$report->plugin->name}") + ->line('**Reported by:** '.$report->user->email) + ->line('**Reason:** '.$report->category->label()) + ->line('**Message:**') + ->line(Str::limit($report->message, 500)) + ->action('Review Report', PluginReportResource::getUrl('view', ['record' => $report])); + } +} diff --git a/app/Policies/PluginRatingPolicy.php b/app/Policies/PluginRatingPolicy.php new file mode 100644 index 000000000..a9b025def --- /dev/null +++ b/app/Policies/PluginRatingPolicy.php @@ -0,0 +1,27 @@ +canRatePlugin($plugin); + } + + public function update(User $user, PluginRating $pluginRating): bool + { + return $user->id === $pluginRating->user_id; + } + + public function delete(User $user, PluginRating $pluginRating): bool + { + return $user->id === $pluginRating->user_id || $user->isAdmin(); + } +} diff --git a/app/Policies/PluginReportPolicy.php b/app/Policies/PluginReportPolicy.php new file mode 100644 index 000000000..e197a4157 --- /dev/null +++ b/app/Policies/PluginReportPolicy.php @@ -0,0 +1,30 @@ +isAdmin(); + } + + public function view(User $user, PluginReport $pluginReport): Response + { + return $user->isAdmin() + ? Response::allow() + : Response::denyAsNotFound('Report not found.'); + } + + public function create(User $user, Plugin $plugin): bool + { + return $user->canReportPlugin($plugin); + } +} diff --git a/config/mail.php b/config/mail.php index e652bd025..d8984256d 100644 --- a/config/mail.php +++ b/config/mail.php @@ -103,6 +103,19 @@ 'name' => env('MAIL_FROM_NAME', 'Example'), ], + /* + |-------------------------------------------------------------------------- + | Support Address + |-------------------------------------------------------------------------- + | + | The mailbox that admin-facing notifications (support tickets, plugin + | messages, plugin reports, showcase/wall-of-love submissions, etc.) are + | routed to via Notification::route('mail', ...). + | + */ + + 'support_address' => env('MAIL_SUPPORT_ADDRESS', 'support@nativephp.com'), + /* |-------------------------------------------------------------------------- | Markdown Mail Settings diff --git a/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php b/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php new file mode 100644 index 000000000..a003be888 --- /dev/null +++ b/database/migrations/2026_09_04_120000_create_plugin_ratings_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->timestamps(); + + $table->unique(['plugin_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_ratings'); + } +}; diff --git a/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php b/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php new file mode 100644 index 000000000..fc19e9a36 --- /dev/null +++ b/database/migrations/2026_09_04_120001_add_rating_average_and_rating_count_to_plugins_table.php @@ -0,0 +1,29 @@ +decimal('rating_average', 3, 2)->nullable()->after('works_in_jump'); + $table->unsignedInteger('rating_count')->default(0)->after('rating_average'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn(['rating_average', 'rating_count']); + }); + } +}; diff --git a/database/migrations/2026_09_04_120002_create_plugin_reports_table.php b/database/migrations/2026_09_04_120002_create_plugin_reports_table.php new file mode 100644 index 000000000..99f542b01 --- /dev/null +++ b/database/migrations/2026_09_04_120002_create_plugin_reports_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('plugin_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('category'); + $table->text('message'); + $table->string('status')->default('open'); + $table->text('resolution_note')->nullable(); + $table->foreignId('resolved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + $table->index(['plugin_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plugin_reports'); + } +}; diff --git a/routes/web.php b/routes/web.php index 8d281a6e7..24ca026dd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,8 @@ use App\Http\Controllers\NotificationUnsubscribeController; use App\Http\Controllers\OpenCollectiveWebhookController; use App\Http\Controllers\PluginDirectoryController; +use App\Http\Controllers\PluginRatingController; +use App\Http\Controllers\PluginReportController; use App\Http\Controllers\PluginWebhookController; use App\Http\Controllers\ProductController; use App\Http\Controllers\ShowBlogController; @@ -243,6 +245,12 @@ Route::get('plugins/marketplace', PluginDirectory::class)->name('plugins.marketplace'); Route::get('plugins/{vendor}/{package}', [PluginDirectoryController::class, 'show'])->name('plugins.show'); Route::get('plugins/{vendor}/{package}/license', [PluginDirectoryController::class, 'license'])->name('plugins.license'); + + Route::middleware('auth')->group(function (): void { + Route::post('plugins/{vendor}/{package}/rating', [PluginRatingController::class, 'store'])->name('plugins.rating.store'); + Route::delete('plugins/{vendor}/{package}/rating', [PluginRatingController::class, 'destroy'])->name('plugins.rating.destroy'); + Route::post('plugins/{vendor}/{package}/report', [PluginReportController::class, 'store'])->name('plugins.report.store'); + }); }); Route::view('sponsor', 'sponsoring')->name('sponsoring'); From 3ac33d7988c42d6c7a6613fed665184be378e627 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 21:39:34 +0200 Subject: [PATCH 2/5] feat(admin): add plugin report moderation and rating management Adds a Filament-only PluginReportResource with resolve/dismiss actions and a nav badge for open reports, plus an open-reports count column on the Plugins list and a RatingsRelationManager so admins can moderate abusive or fake ratings. Co-Authored-By: Claude Sonnet 5 --- .../Resources/PluginReportResource.php | 218 ++++++++++++++++++ .../Pages/ListPluginReports.php | 18 ++ .../Pages/ViewPluginReport.php | 73 ++++++ app/Filament/Resources/PluginResource.php | 10 + .../RatingsRelationManager.php | 56 +++++ 5 files changed, 375 insertions(+) create mode 100644 app/Filament/Resources/PluginReportResource.php create mode 100644 app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php create mode 100644 app/Filament/Resources/PluginReportResource/Pages/ViewPluginReport.php create mode 100644 app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php diff --git a/app/Filament/Resources/PluginReportResource.php b/app/Filament/Resources/PluginReportResource.php new file mode 100644 index 000000000..7a3c7bd63 --- /dev/null +++ b/app/Filament/Resources/PluginReportResource.php @@ -0,0 +1,218 @@ +open()->count(); + + return $count > 0 ? (string) $count : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'danger'; + } + + public static function form(Schema $schema): Schema + { + return $schema->schema([]); + } + + private static function resolutionActions(): array + { + return [ + Actions\Action::make('resolve') + ->label('Mark Resolved') + ->icon('heroicon-o-check-circle') + ->color('success') + ->visible(fn (PluginReport $record): bool => $record->isOpen()) + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (PluginReport $record, array $data): void { + $record->resolve(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report marked resolved') + ->success() + ->send(); + }), + + Actions\Action::make('dismiss') + ->label('Dismiss') + ->icon('heroicon-o-x-circle') + ->color('gray') + ->visible(fn (PluginReport $record): bool => $record->isOpen()) + ->requiresConfirmation() + ->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.') + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (PluginReport $record, array $data): void { + $record->dismiss(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report dismissed') + ->success() + ->send(); + }), + ]; + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->inlineLabel() + ->columns(1) + ->schema([ + Schemas\Components\Section::make('Report Details') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('plugin.name') + ->label('Plugin'), + Infolists\Components\TextEntry::make('user.email') + ->label('Reported By') + ->copyable(), + Infolists\Components\TextEntry::make('category') + ->badge() + ->formatStateUsing(fn (PluginReportCategory $state): string => $state->label()), + Infolists\Components\TextEntry::make('status') + ->badge() + ->color(fn (PluginReportStatus $state): string => $state->color()) + ->formatStateUsing(fn (PluginReportStatus $state): string => $state->label()), + Infolists\Components\TextEntry::make('message') + ->label('Report Message') + ->columnSpanFull(), + Infolists\Components\TextEntry::make('created_at') + ->label('Filed At') + ->dateTime(), + ]), + + Schemas\Components\Section::make('Resolution') + ->inlineLabel() + ->columns(1) + ->schema([ + Infolists\Components\TextEntry::make('resolvedBy.email') + ->label('Resolved By') + ->placeholder('—'), + Infolists\Components\TextEntry::make('resolved_at') + ->label('Resolved At') + ->dateTime() + ->placeholder('—'), + Infolists\Components\TextEntry::make('resolution_note') + ->label('Note') + ->placeholder('—') + ->columnSpanFull(), + ]) + ->visible(fn (PluginReport $record): bool => ! $record->isOpen()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('plugin.name') + ->label('Plugin') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('user.email') + ->label('Reported By') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('category') + ->badge() + ->formatStateUsing(fn (PluginReportCategory $state): string => $state->label()) + ->sortable(), + + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (PluginReportStatus $state): string => $state->color()) + ->formatStateUsing(fn (PluginReportStatus $state): string => $state->label()) + ->sortable(), + + Tables\Columns\TextColumn::make('message') + ->limit(80) + ->wrap() + ->tooltip(fn (PluginReport $record): string => $record->message), + + Tables\Columns\TextColumn::make('created_at') + ->label('Filed') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options(PluginReportStatus::class), + Tables\Filters\SelectFilter::make('category') + ->options(PluginReportCategory::class), + ]) + ->actions([ + Actions\ViewAction::make(), + ...self::resolutionActions(), + ]) + ->bulkActions([]) + ->defaultSort('created_at', 'desc') + ->recordUrl( + fn ($record) => self::getUrl('view', ['record' => $record]) + ); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPluginReports::route('/'), + 'view' => Pages\ViewPluginReport::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php b/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php new file mode 100644 index 000000000..2710e9efe --- /dev/null +++ b/app/Filament/Resources/PluginReportResource/Pages/ListPluginReports.php @@ -0,0 +1,18 @@ +label('Mark Resolved') + ->icon('heroicon-o-check-circle') + ->color('success') + ->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen()) + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (array $data): void { + /** @var PluginReport $report */ + $report = $this->record; + $report->resolve(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report marked resolved') + ->success() + ->send(); + + $this->refreshFormData(['status']); + }), + + Actions\Action::make('dismiss') + ->label('Dismiss') + ->icon('heroicon-o-x-circle') + ->color('gray') + ->visible(fn (): bool => $this->record instanceof PluginReport && $this->record->isOpen()) + ->requiresConfirmation() + ->modalDescription('Dismiss this report as not actionable? It will be removed from the open reports count.') + ->form([ + Forms\Components\Textarea::make('resolution_note') + ->label('Resolution Note') + ->helperText('Optional internal note — not visible to the reporter or the plugin author.') + ->rows(3), + ]) + ->action(function (array $data): void { + /** @var PluginReport $report */ + $report = $this->record; + $report->dismiss(Auth::user(), $data['resolution_note'] ?: null); + + Notification::make() + ->title('Report dismissed') + ->success() + ->send(); + + $this->refreshFormData(['status']); + }), + ]; + } +} diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index e1cec0716..79f5ff4c1 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -258,6 +258,9 @@ public static function form(Schema $schema): Schema public static function table(Table $table): Table { return $table + ->modifyQueryUsing(fn (Builder $query): Builder => $query->withCount([ + 'reports as open_reports_count' => fn (Builder $query): Builder => $query->open(), + ])) ->columns([ Tables\Columns\ImageColumn::make('logo_path') ->label('Logo') @@ -307,6 +310,12 @@ public static function table(Table $table): Table ->sortable() ->toggleable(), + Tables\Columns\TextColumn::make('open_reports_count') + ->label('Reports') + ->badge() + ->color(fn (int $state): string => $state > 0 ? 'danger' : 'gray') + ->sortable(), + Tables\Columns\TextColumn::make('mobile_min_version') ->label('Mobile SDK') ->placeholder('-') @@ -544,6 +553,7 @@ public static function getRelations(): array RelationManagers\PricesRelationManager::class, RelationManagers\LicensesRelationManager::class, RelationManagers\ActivitiesRelationManager::class, + RelationManagers\RatingsRelationManager::class, ]; } diff --git a/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php new file mode 100644 index 000000000..05ce0a124 --- /dev/null +++ b/app/Filament/Resources/PluginResource/RelationManagers/RatingsRelationManager.php @@ -0,0 +1,56 @@ +columns([ + Tables\Columns\TextColumn::make('rating') + ->label('Stars') + ->formatStateUsing(fn (int $state): string => str_repeat('★', $state).str_repeat('☆', 5 - $state)) + ->sortable(), + + Tables\Columns\TextColumn::make('user.email') + ->label('User') + ->searchable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Rated') + ->dateTime() + ->sortable(), + + Tables\Columns\TextColumn::make('updated_at') + ->label('Last Changed') + ->dateTime() + ->sortable(), + ]) + ->headerActions([]) + ->actions([ + Actions\DeleteAction::make() + ->label('Remove') + ->modalHeading('Remove Rating') + ->modalDescription('This will delete the rating and recalculate the plugin\'s average. Use this to moderate abusive or fake ratings.'), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc') + ->paginated([10, 25, 50]); + } +} From a3d6934e7a12357ba10d5dedeec5bb72ab6e98df Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 21:39:40 +0200 Subject: [PATCH 3/5] feat(plugins): add rating and report UI to the public plugin page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows the star average/count in the header, a rating widget for eligible logged-in users, and a collapsible "Report this plugin" panel. The report panel is not for bugs — it surfaces the plugin's GitHub Issues link (or its support channel) first, before the report form itself. Co-Authored-By: Claude Sonnet 5 --- resources/views/plugin-show.blade.php | 184 +++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 4 deletions(-) diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index e3cbff3a0..817695e81 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -95,8 +95,22 @@ class="text-2xl font-bold sm:text-3xl" {{ $plugin->description }}

@endif - @if ($plugin->worksInJump()) -
+
+ @if ($plugin->rating_count > 0) + + + {{ number_format($plugin->rating_average, 1) }} + ({{ $plugin->rating_count }} {{ Str::plural('rating', $plugin->rating_count) }}) + + @else + No ratings yet + @endif + + @if ($plugin->worksInJump()) -
- @endif + @endif +
@@ -113,6 +127,19 @@ class="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 tex {{-- Divider --}} + {{-- Session Messages --}} + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif +
{{-- Main content - README --}}
@@ -575,6 +602,155 @@ class="size-10 shrink-0 rounded-lg object-cover"
@endif + {{-- Rate this plugin --}} + @auth + @if (auth()->user()->canRatePlugin($plugin)) + @php $myRating = \App\Models\PluginRating::findFor($plugin, auth()->user()); @endphp +
+

+ {{ $myRating ? 'Your Rating' : 'Rate this Plugin' }} +

+
+ @csrf + +
+ @for ($i = 1; $i <= 5; $i++) + + @endfor +
+ @error('rating') +

{{ $message }}

+ @enderror +
+ @if ($myRating) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ @endif + @else +
+

+ Log in + to rate this plugin. +

+
+ @endauth + + {{-- Report this plugin --}} + @if (auth()->check() && auth()->user()->canReportPlugin($plugin)) +
+ + +
+
+

+ This isn't for bugs or support requests — it's only for reporting a plugin that's + malicious or whose author is unresponsive to the NativePHP team. +

+ @if ($plugin->getIssuesUrl()) +

+ Found a bug instead? + + Report it on GitHub → + +

+ @elseif ($plugin->support_channel) +

+ Found a bug instead? Use the Support link above to contact the plugin's author. +

+ @endif +
+ +
+ @csrf +
+ + + @error('category') +

{{ $message }}

+ @enderror +
+
+ + + @error('message') +

{{ $message }}

+ @enderror +
+ +
+
+
+ @endif +
From 24ec70fdb7f88ceef282765cfcd979aeebfdcdc6 Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 4 Sep 2026 21:39:50 +0200 Subject: [PATCH 4/5] test: cover plugin ratings and reports Co-Authored-By: Claude Sonnet 5 --- database/factories/PluginRatingFactory.php | 28 +++ database/factories/PluginReportFactory.php | 50 ++++++ .../PluginRatingsRelationManagerTest.php | 77 ++++++++ .../Filament/PluginReportResourceTest.php | 121 +++++++++++++ tests/Feature/PluginRatingTest.php | 164 ++++++++++++++++++ tests/Feature/PluginReportTest.php | 129 ++++++++++++++ 6 files changed, 569 insertions(+) create mode 100644 database/factories/PluginRatingFactory.php create mode 100644 database/factories/PluginReportFactory.php create mode 100644 tests/Feature/Filament/PluginRatingsRelationManagerTest.php create mode 100644 tests/Feature/Filament/PluginReportResourceTest.php create mode 100644 tests/Feature/PluginRatingTest.php create mode 100644 tests/Feature/PluginReportTest.php diff --git a/database/factories/PluginRatingFactory.php b/database/factories/PluginRatingFactory.php new file mode 100644 index 000000000..9698bb4bf --- /dev/null +++ b/database/factories/PluginRatingFactory.php @@ -0,0 +1,28 @@ + + */ +class PluginRatingFactory extends Factory +{ + protected $model = PluginRating::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'plugin_id' => Plugin::factory(), + 'user_id' => User::factory(), + 'rating' => $this->faker->numberBetween(1, 5), + ]; + } +} diff --git a/database/factories/PluginReportFactory.php b/database/factories/PluginReportFactory.php new file mode 100644 index 000000000..9596b2c12 --- /dev/null +++ b/database/factories/PluginReportFactory.php @@ -0,0 +1,50 @@ + + */ +class PluginReportFactory extends Factory +{ + protected $model = PluginReport::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'plugin_id' => Plugin::factory(), + 'user_id' => User::factory(), + 'category' => $this->faker->randomElement(PluginReportCategory::cases()), + 'message' => $this->faker->paragraph(), + 'status' => PluginReportStatus::Open, + ]; + } + + public function resolved(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginReportStatus::Resolved, + 'resolved_at' => now(), + 'resolved_by' => User::factory(), + ]); + } + + public function dismissed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PluginReportStatus::Dismissed, + 'resolved_at' => now(), + 'resolved_by' => User::factory(), + ]); + } +} diff --git a/tests/Feature/Filament/PluginRatingsRelationManagerTest.php b/tests/Feature/Filament/PluginRatingsRelationManagerTest.php new file mode 100644 index 000000000..e3eb3739e --- /dev/null +++ b/tests/Feature/Filament/PluginRatingsRelationManagerTest.php @@ -0,0 +1,77 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $this->plugin = Plugin::factory()->approved()->free()->create(); + } + + public function test_it_lists_ratings_for_the_plugin(): void + { + PluginRating::submit($this->plugin, User::factory()->create(['email' => 'rater@example.com']), 5); + + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->assertSuccessful() + ->assertSee('rater@example.com') + ->assertCountTableRecords(1); + } + + public function test_admin_can_delete_an_abusive_rating_and_the_average_recalculates(): void + { + PluginRating::submit($this->plugin, User::factory()->create(), 5); + $rating = PluginRating::submit($this->plugin, User::factory()->create(), 1); + + $this->plugin->refresh(); + $this->assertEquals(3.00, $this->plugin->rating_average); + + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->callTableAction('delete', $rating); + + $this->assertDatabaseMissing('plugin_ratings', ['id' => $rating->id]); + + $this->plugin->refresh(); + $this->assertEquals(5.00, $this->plugin->rating_average); + $this->assertEquals(1, $this->plugin->rating_count); + } + + public function test_it_shows_zero_ratings_for_a_plugin_with_none(): void + { + Livewire::actingAs($this->admin) + ->test(RatingsRelationManager::class, [ + 'ownerRecord' => $this->plugin, + 'pageClass' => EditPlugin::class, + ]) + ->assertCountTableRecords(0); + } +} diff --git a/tests/Feature/Filament/PluginReportResourceTest.php b/tests/Feature/Filament/PluginReportResourceTest.php new file mode 100644 index 000000000..3579a02fb --- /dev/null +++ b/tests/Feature/Filament/PluginReportResourceTest.php @@ -0,0 +1,121 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_list_page_renders_successfully(): void + { + PluginReport::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertSuccessful(); + } + + public function test_list_page_shows_plugin_and_reporter(): void + { + $plugin = Plugin::factory()->create(['name' => 'acme/super-plugin']); + $reporter = User::factory()->create(['email' => 'reporter@example.com']); + + PluginReport::factory()->create([ + 'plugin_id' => $plugin->id, + 'user_id' => $reporter->id, + 'category' => PluginReportCategory::MaliciousCode, + ]); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertSuccessful() + ->assertSee('acme/super-plugin') + ->assertSee('reporter@example.com'); + } + + public function test_non_admin_cannot_view_any_reports(): void + { + $user = User::factory()->create(); + + $this->assertFalse($user->can('viewAny', PluginReport::class)); + } + + public function test_view_page_renders_successfully(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ViewPluginReport::class, ['record' => $report->getRouteKey()]) + ->assertSuccessful(); + } + + public function test_resolve_action_marks_the_report_resolved(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->callTableAction('resolve', $report, data: [ + 'resolution_note' => 'Investigated, plugin is fine.', + ]); + + $report->refresh(); + $this->assertEquals(PluginReportStatus::Resolved, $report->status); + $this->assertEquals($this->admin->id, $report->resolved_by); + $this->assertNotNull($report->resolved_at); + $this->assertEquals('Investigated, plugin is fine.', $report->resolution_note); + } + + public function test_dismiss_action_marks_the_report_dismissed(): void + { + $report = PluginReport::factory()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->callTableAction('dismiss', $report, data: [ + 'resolution_note' => 'Not actionable.', + ]); + + $report->refresh(); + $this->assertEquals(PluginReportStatus::Dismissed, $report->status); + } + + public function test_resolve_action_is_hidden_for_already_resolved_reports(): void + { + $report = PluginReport::factory()->resolved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPluginReports::class) + ->assertTableActionHidden('resolve', $report); + } + + public function test_navigation_badge_counts_only_open_reports(): void + { + PluginReport::factory()->count(2)->create(); + PluginReport::factory()->resolved()->create(); + + $this->assertEquals('2', PluginReportResource::getNavigationBadge()); + } +} diff --git a/tests/Feature/PluginRatingTest.php b/tests/Feature/PluginRatingTest.php new file mode 100644 index 000000000..38aee64ea --- /dev/null +++ b/tests/Feature/PluginRatingTest.php @@ -0,0 +1,164 @@ +approved()->free()->create(); + + $response = $this->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertRedirect(route('customer.login')); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_logged_in_user_can_rate_a_free_plugin(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 4, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 4, + ]); + + $plugin->refresh(); + $this->assertEquals(4.00, $plugin->rating_average); + $this->assertEquals(1, $plugin->rating_count); + } + + public function test_user_cannot_rate_their_own_plugin(): void + { + $owner = User::factory()->create(); + $plugin = Plugin::factory()->approved()->free()->for($owner)->create(); + + $response = $this->actingAs($owner)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_without_a_license_cannot_rate_a_paid_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 5, + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_with_a_license_can_rate_a_paid_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(); + $user = User::factory()->create(); + + PluginLicense::factory()->for($user)->for($plugin)->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 3, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 3, + ]); + } + + public function test_rating_again_updates_the_existing_rating_instead_of_creating_a_new_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 2]); + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 5]); + + $this->assertDatabaseCount('plugin_ratings', 1); + $this->assertDatabaseHas('plugin_ratings', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'rating' => 5, + ]); + + $plugin->refresh(); + $this->assertEquals(5.00, $plugin->rating_average); + $this->assertEquals(1, $plugin->rating_count); + } + + public function test_rating_is_rejected_outside_the_1_to_5_range(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), [ + 'rating' => 6, + ]); + + $response->assertSessionHasErrors('rating'); + $this->assertDatabaseCount('plugin_ratings', 0); + } + + public function test_user_can_remove_their_own_rating(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('plugins.rating.store', $plugin->routeParams()), ['rating' => 4]); + + $response = $this->actingAs($user)->delete(route('plugins.rating.destroy', $plugin->routeParams())); + + $response->assertRedirect(); + $this->assertDatabaseCount('plugin_ratings', 0); + + $plugin->refresh(); + $this->assertNull($plugin->rating_average); + $this->assertEquals(0, $plugin->rating_count); + } + + public function test_average_rating_recalculates_across_multiple_users(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 5); + PluginRating::submit($plugin, User::factory()->create(), 3); + + $plugin->refresh(); + $this->assertEquals(4.00, $plugin->rating_average); + $this->assertEquals(2, $plugin->rating_count); + } +} diff --git a/tests/Feature/PluginReportTest.php b/tests/Feature/PluginReportTest.php new file mode 100644 index 000000000..a4925e8a2 --- /dev/null +++ b/tests/Feature/PluginReportTest.php @@ -0,0 +1,129 @@ +approved()->create(); + + $response = $this->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'This plugin exfiltrates data.', + ]); + + $response->assertRedirect(route('customer.login')); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_logged_in_user_can_report_a_plugin_and_admins_are_notified(): void + { + Notification::fake(); + + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::UnresponsiveAuthor->value, + 'message' => "I've messaged the author for weeks with no response.", + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('plugin_reports', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'category' => PluginReportCategory::UnresponsiveAuthor->value, + 'status' => PluginReportStatus::Open->value, + ]); + + Notification::assertSentOnDemand( + PluginReported::class, + fn (PluginReported $notification, array $channels, object $notifiable): bool => $notifiable->routes['mail'] === config('mail.support_address') + && $notification->report->plugin->is($plugin) + ); + } + + public function test_user_cannot_report_their_own_plugin(): void + { + $owner = User::factory()->create(); + $plugin = Plugin::factory()->approved()->for($owner)->create(); + + $response = $this->actingAs($owner)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::Other->value, + 'message' => 'Testing self-report.', + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_report_requires_a_valid_category_and_message(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => 'not-a-real-category', + 'message' => '', + ]); + + $response->assertSessionHasErrors(['category', 'message']); + $this->assertDatabaseCount('plugin_reports', 0); + } + + public function test_user_cannot_file_a_second_open_report_for_the_same_plugin(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + + PluginReport::file($plugin, $user, PluginReportCategory::MaliciousCode, 'First report.'); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'Second report while the first is still open.', + ]); + + $response->assertSessionHas('error'); + $this->assertDatabaseCount('plugin_reports', 1); + } + + public function test_user_can_file_a_new_report_once_their_previous_one_is_resolved(): void + { + $plugin = Plugin::factory()->approved()->create(); + $user = User::factory()->create(); + $admin = User::factory()->create(); + + $firstReport = PluginReport::file($plugin, $user, PluginReportCategory::MaliciousCode, 'First report.'); + $firstReport->resolve($admin); + + $response = $this->actingAs($user)->post(route('plugins.report.store', $plugin->routeParams()), [ + 'category' => PluginReportCategory::MaliciousCode->value, + 'message' => 'Second report after the first was resolved.', + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('plugin_reports', 2); + } +} From 4e9639c6c035a71e6d7071ac61c4ee5857f4cf6a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 5 Sep 2026 16:41:01 +0100 Subject: [PATCH 5/5] feat(plugins): send users back to the plugin page after logging in to rate The "Log in to rate this plugin" link now passes the plugin page as a redirect param, which showLogin() stores as url.intended. Login, registration and GitHub sign-in all already end in redirect()->intended(), so all three routes back to the plugin. Only same-site paths are accepted so the param can't be used as an open redirect. Also hides the rating in the plugin header until there is at least one, dropping the "No ratings yet" line, and shows the average on the marketplace cards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013QiSkWpoC7KpTegPeM1Z78 --- .../Auth/CustomerAuthController.php | 25 ++++- .../views/components/plugin-card.blade.php | 23 ++++- resources/views/plugin-show.blade.php | 4 +- tests/Feature/PluginRatingTest.php | 96 +++++++++++++++++++ 4 files changed, 139 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 880ca4c65..7e2847f87 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -23,8 +23,10 @@ class CustomerAuthController extends Controller { public function __construct(protected CartService $cartService) {} - public function showLogin(): View + public function showLogin(Request $request): View { + $this->rememberIntendedUrl($request); + return view('auth.login'); } @@ -172,6 +174,27 @@ function ($user, $password): void { : back()->withErrors(['email' => [__($status)]]); } + /** + * Remember where the user was headed, so login sends them back there. + * + * Only same-site paths are accepted; anything else is ignored so the + * `redirect` parameter can't be used as an open redirect. + */ + private function rememberIntendedUrl(Request $request): void + { + $redirect = $request->query('redirect'); + + if (! is_string($redirect) || ! str_starts_with($redirect, '/')) { + return; + } + + if (str_starts_with($redirect, '//') || str_starts_with($redirect, '/\\')) { + return; + } + + session(['url.intended' => url($redirect)]); + } + private function acceptPendingTeamInvitation(User $user): void { $token = session()->pull('pending_team_invitation_token'); diff --git a/resources/views/components/plugin-card.blade.php b/resources/views/components/plugin-card.blade.php index 9d9f0807b..1f8dda120 100644 --- a/resources/views/components/plugin-card.blade.php +++ b/resources/views/components/plugin-card.blade.php @@ -61,10 +61,23 @@ class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 t @endif -
- View details - - - +
+ + View details + + + + + + @if ($plugin->rating_count > 0) + + + @endif
diff --git a/resources/views/plugin-show.blade.php b/resources/views/plugin-show.blade.php index 817695e81..258cfad1b 100644 --- a/resources/views/plugin-show.blade.php +++ b/resources/views/plugin-show.blade.php @@ -106,8 +106,6 @@ class="text-2xl font-bold sm:text-3xl" {{ number_format($plugin->rating_average, 1) }} ({{ $plugin->rating_count }} {{ Str::plural('rating', $plugin->rating_count) }}) - @else - No ratings yet @endif @if ($plugin->worksInJump()) @@ -650,7 +648,7 @@ class="size-7 transition" @else

- Log in + Log in to rate this plugin.

diff --git a/tests/Feature/PluginRatingTest.php b/tests/Feature/PluginRatingTest.php index 38aee64ea..13829e213 100644 --- a/tests/Feature/PluginRatingTest.php +++ b/tests/Feature/PluginRatingTest.php @@ -3,12 +3,14 @@ namespace Tests\Feature; use App\Features\ShowPlugins; +use App\Livewire\PluginDirectory; use App\Models\Plugin; use App\Models\PluginLicense; use App\Models\PluginRating; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Pennant\Feature; +use Livewire\Livewire; use Tests\TestCase; class PluginRatingTest extends TestCase @@ -161,4 +163,98 @@ public function test_average_rating_recalculates_across_multiple_users(): void $this->assertEquals(4.00, $plugin->rating_average); $this->assertEquals(2, $plugin->rating_count); } + + public function test_plugin_page_says_nothing_about_ratings_until_there_is_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertDontSee('No ratings yet') + ->assertDontSee('out of 5 stars'); + } + + public function test_plugin_page_shows_the_average_once_the_plugin_has_a_rating(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 4); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertSee('4.0') + ->assertSee('(1 rating)'); + } + + public function test_plugin_card_shows_the_rating_when_the_plugin_has_one(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + PluginRating::submit($plugin, User::factory()->create(), 5); + PluginRating::submit($plugin, User::factory()->create(), 4); + + Livewire::test(PluginDirectory::class) + ->assertSee('4.5') + ->assertSee('4.5 out of 5 stars from 2 ratings'); + } + + public function test_plugin_card_hides_the_rating_when_the_plugin_has_none(): void + { + Plugin::factory()->approved()->free()->create(); + + Livewire::test(PluginDirectory::class) + ->assertDontSee('out of 5 stars'); + } + + public function test_login_link_on_the_plugin_page_carries_the_plugin_page_as_the_destination(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertOk() + ->assertSee(route('customer.login', [ + 'redirect' => route('plugins.show', $plugin->routeParams(), false), + ])); + } + + public function test_user_is_returned_to_the_plugin_page_after_logging_in_to_rate_it(): void + { + $plugin = Plugin::factory()->approved()->free()->create(); + $user = User::factory()->create(); + + $this->get(route('customer.login', [ + 'redirect' => route('plugins.show', $plugin->routeParams(), false), + ]))->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('plugins.show', $plugin->routeParams())); + } + + public function test_login_redirect_ignores_a_destination_outside_the_site(): void + { + $user = User::factory()->create(); + + $this->get(route('customer.login', ['redirect' => 'https://evil.example.com/phish'])) + ->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard')); + } + + public function test_login_redirect_ignores_a_protocol_relative_destination(): void + { + $user = User::factory()->create(); + + $this->get(route('customer.login', ['redirect' => '//evil.example.com/phish'])) + ->assertOk(); + + $this->post(route('customer.login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard')); + } }