Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 15 additions & 5 deletions app/Filament/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace App\Filament\Resources;

use App\Enums\StripeConnectStatus;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
Expand Down Expand Up @@ -38,11 +37,11 @@ public static function form(Schema $schema): Schema
->email()
->required()
->maxLength(255),
Forms\Components\DateTimePicker::make('email_verified_at'),
Forms\Components\TextInput::make('password')
->password()
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context): bool => $context === 'create')
->hidden(fn (string $context): bool => $context === 'edit')
->maxLength(255),
]),
Schemas\Components\Section::make('Billing Information')
Expand All @@ -64,15 +63,26 @@ public static function form(Schema $schema): Schema
->maxLength(255)
->disabled(),
]),
Schemas\Components\Section::make('Notifications')
->description('Once these are disabled, they cannot be re-enabled by an admin.')
->inlineLabel()
->columns(1)
->schema([
Forms\Components\Toggle::make('receives_notification_emails')
->label('Email notifications')
->disabled(fn (?User $record) => $record && ! $record->receives_notification_emails),
Forms\Components\Toggle::make('receives_new_plugin_notifications')
->label('New plugin notifications')
->disabled(fn (?User $record) => $record && ! $record->receives_new_plugin_notifications),
]),
Schemas\Components\Section::make('Developer Account')
->inlineLabel()
->columns(1)
->visible(fn (?User $record) => $record?->developerAccount !== null)
->schema([
Forms\Components\Select::make('developerAccount.stripe_connect_status')
Forms\Components\Placeholder::make('developerAccount.stripe_connect_status')
->label('Stripe Connect Status')
->options(StripeConnectStatus::class)
->disabled(),
->content(fn (User $record) => $record->developerAccount->stripe_connect_status?->label() ?? '—'),
Forms\Components\Placeholder::make('developerAccount.stripe_connect_account_id')
->label('Stripe Connect Account')
->content(fn (User $record) => new HtmlString(
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Controllers/Auth/CustomerAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Models\TeamUser;
use App\Models\User;
use App\Services\CartService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -45,6 +46,8 @@ public function register(Request $request): RedirectResponse
'password' => Hash::make($request->password),
]);

event(new Registered($user));

Auth::login($user);

// Transfer guest cart to user
Expand Down
30 changes: 30 additions & 0 deletions app/Http/Controllers/Auth/EmailVerificationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class EmailVerificationController extends Controller
{
public function notice(): RedirectResponse
{
return to_route('dashboard');
}

public function verify(EmailVerificationRequest $request): RedirectResponse
{
$request->fulfill();

return to_route('dashboard')->with('success', 'Your email address has been verified.');
}

public function resend(Request $request): RedirectResponse
{
$request->user()->sendEmailVerificationNotification();

return back()->with('status', 'A new verification link has been sent to your email address.');
}
}
65 changes: 65 additions & 0 deletions app/Http/Controllers/NotificationUnsubscribeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class NotificationUnsubscribeController extends Controller
{
public function unsubscribe(Request $request, User $user): RedirectResponse|View
{
$user->update(['receives_new_plugin_notifications' => false]);

if ($request->user()?->is($user)) {
return redirect()
->route('customer.settings', ['tab' => 'notifications'])
->with('new-plugin-notifications-disabled', true);
}

return view('notifications.unsubscribed', [
'maskedEmail' => $this->maskEmail($user->email),
'resubscribeUrl' => $this->signedResubscribeUrl($user),
]);
}

public function resubscribe(Request $request, User $user): RedirectResponse|View
{
$user->update(['receives_new_plugin_notifications' => true]);

if ($request->user()?->is($user)) {
return redirect()
->route('customer.settings', ['tab' => 'notifications'])
->with('new-plugin-notifications-enabled', true);
}

return view('notifications.resubscribed', [
'maskedEmail' => $this->maskEmail($user->email),
]);
}

public static function signedUnsubscribeUrl(User $user): string
{
return url()->signedRoute('notifications.unsubscribe', ['user' => $user]);
}

private function signedResubscribeUrl(User $user): string
{
return url()->signedRoute('notifications.resubscribe', ['user' => $user]);
}

private function maskEmail(string $email): string
{
[$local, $domain] = explode('@', $email);

if (strlen($local) <= 2) {
$maskedLocal = $local[0].str_repeat('*', max(1, strlen($local) - 1));
} else {
$maskedLocal = $local[0].str_repeat('*', strlen($local) - 2).$local[strlen($local) - 1];
}

return $maskedLocal.'@'.$domain;
}
}
8 changes: 7 additions & 1 deletion app/Listeners/SuppressMailNotificationListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Listeners;

use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Events\NotificationSending;

class SuppressMailNotificationListener
Expand All @@ -17,6 +18,11 @@ public function handle(NotificationSending $event): bool
return true;
}

return $event->notifiable->receives_notification_emails;
// System notifications like email verification should always be sent
if ($event->notification instanceof VerifyEmail) {
return true;
}

return (bool) $event->notifiable->receives_notification_emails;
}
}
4 changes: 2 additions & 2 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\PriceTier;
use App\Enums\Subscription;
use App\Enums\TeamUserStatus;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
Expand All @@ -19,7 +19,7 @@
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements FilamentUser, HasName
class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail
{
use Billable, HasApiTokens, HasFactory, Notifiable;

Expand Down
7 changes: 6 additions & 1 deletion app/Notifications/NewPluginAvailable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace App\Notifications;

use App\Http\Controllers\NotificationUnsubscribeController;
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
Expand Down Expand Up @@ -30,12 +32,15 @@ public function via(object $notifiable): array

public function toMail(object $notifiable): MailMessage
{
/** @var User $notifiable */
$unsubscribeUrl = NotificationUnsubscribeController::signedUnsubscribeUrl($notifiable);

return (new MailMessage)
->subject("New Plugin: {$this->plugin->name}")
->greeting('A new plugin is available!')
->line("**{$this->plugin->name}** has just been added to the NativePHP Plugin Marketplace.")
->action('View Plugin', route('plugins.show', $this->plugin->routeParams()))
->line('[Manage your notification preferences]('.route('customer.settings', ['tab' => 'notifications']).').');
->line('[Unsubscribe from new plugin notifications]('.$unsubscribeUrl.').');
}

/**
Expand Down
20 changes: 20 additions & 0 deletions resources/views/livewire/customer/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@
<flux:text>Welcome back, {{ auth()->user()->first_name ?? auth()->user()->name }}</flux:text>
</div>

{{-- Email Verification Banner --}}
@if (!auth()->user()->hasVerifiedEmail())
<flux:callout variant="warning" icon="envelope" class="mb-6">
<flux:callout.heading>Please verify your email address.</flux:callout.heading>
<flux:callout.text>
We sent a verification email when you registered. Click the link in that email to verify your account.

@if (session('status'))
<span class="font-medium">{{ session('status') }}</span>
@endif
</flux:callout.text>
<x-slot:actions>
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<flux:button type="submit" variant="filled" size="sm">Resend verification email</flux:button>
</form>
</x-slot:actions>
</flux:callout>
@endif

{{-- Session Messages --}}
@if (session('success'))
<flux:callout variant="success" icon="check-circle" class="mb-6">
Expand Down
12 changes: 12 additions & 0 deletions resources/views/livewire/customer/settings.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi
@endif

@if ($tab === 'notifications')
@if (session('new-plugin-notifications-disabled'))
<flux:callout variant="success" icon="check-circle" class="mb-6">
<flux:callout.text>New plugin notifications have been disabled.</flux:callout.text>
</flux:callout>
@endif

@if (session('new-plugin-notifications-enabled'))
<flux:callout variant="success" icon="check-circle" class="mb-6">
<flux:callout.text>New plugin notifications have been re-enabled.</flux:callout.text>
</flux:callout>
@endif

<flux:card class="space-y-6">
<flux:switch
wire:model.live="receivesNotificationEmails"
Expand Down
21 changes: 21 additions & 0 deletions resources/views/notifications/resubscribed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<x-layouts.auth>
<x-slot:title>Notifications Enabled</x-slot:title>

<flux:card class="space-y-4">
<div class="text-center">
<flux:icon.check-circle variant="solid" class="mx-auto size-12 text-green-500" />
</div>

<flux:heading size="lg" class="text-center">Notifications Enabled</flux:heading>

<flux:text class="text-center">
New plugin notifications have been turned back on for <strong>{{ $maskedEmail }}</strong>.
</flux:text>

<div class="flex justify-center">
<flux:button :href="route('welcome')">
Go to Homepage
</flux:button>
</div>
</flux:card>
</x-layouts.auth>
25 changes: 25 additions & 0 deletions resources/views/notifications/unsubscribed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<x-layouts.auth>
<x-slot:title>Notifications Disabled</x-slot:title>

<flux:card class="space-y-4">
<div class="text-center">
<flux:icon.check-circle variant="solid" class="mx-auto size-12 text-green-500" />
</div>

<flux:heading size="lg" class="text-center">Notifications Disabled</flux:heading>

<flux:text class="text-center">
New plugin notifications have been turned off for <strong>{{ $maskedEmail }}</strong>.
</flux:text>

<flux:text class="text-center text-sm">
Did this happen by mistake?
</flux:text>

<div class="flex justify-center">
<flux:button :href="$resubscribeUrl" variant="primary">
Re-enable Notifications
</flux:button>
</div>
</flux:card>
</x-layouts.auth>
15 changes: 15 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use App\Features\ShowPlugins;
use App\Http\Controllers\ApplinksController;
use App\Http\Controllers\Auth\CustomerAuthController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\BundleController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\CustomerLicenseController;
Expand All @@ -13,6 +14,7 @@
use App\Http\Controllers\GitHubAuthController;
use App\Http\Controllers\GitHubIntegrationController;
use App\Http\Controllers\LicenseRenewalController;
use App\Http\Controllers\NotificationUnsubscribeController;
use App\Http\Controllers\OpenCollectiveWebhookController;
use App\Http\Controllers\PluginDirectoryController;
use App\Http\Controllers\PluginWebhookController;
Expand Down Expand Up @@ -288,6 +290,13 @@
Route::get('auth/github/login', [GitHubAuthController::class, 'redirect'])->name('login.github');
});

// Email verification routes
Route::middleware('auth')->group(function (): void {
Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice');
Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send');
});

Route::post('logout', [CustomerAuthController::class, 'logout'])
->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class))
->name('customer.logout');
Expand Down Expand Up @@ -406,6 +415,12 @@
Route::post('licenses/{licenseKey}/sub-licenses/{subLicense}/send-email', [CustomerSubLicenseController::class, 'sendEmail'])->name('licenses.sub-licenses.send-email');
});

// Notification unsubscribe/resubscribe (signed URLs, no auth required)
Route::middleware('signed')->group(function (): void {
Route::get('notifications/unsubscribe/{user}', [NotificationUnsubscribeController::class, 'unsubscribe'])->name('notifications.unsubscribe');
Route::get('notifications/resubscribe/{user}', [NotificationUnsubscribeController::class, 'resubscribe'])->name('notifications.resubscribe');
});

Route::get('.well-known/assetlinks.json', [ApplinksController::class, 'assetLinks']);

Route::post('webhooks/plugins/{secret}', PluginWebhookController::class)->name('webhooks.plugins');
Expand Down
Loading
Loading