feat(mailbox): Implement cinematic UX and user tiers
- Added Spatie roles (free, pro, enterprise, admin) and access scopes - Implemented delayed, cinematic mailbox provisioning animation - Fixed GSAP and SVG collision issues on creation overlay - Improved component sync with livewire refresh - Added feature tests for tier systems and fixed RegistrationTest
This commit is contained in:
@@ -35,6 +35,7 @@ class Register extends Component
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
event(new Registered(($user = User::create($validated))));
|
||||
$user->assignRole('free');
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ class Mailbox extends Component
|
||||
|
||||
public $customDomain = '';
|
||||
|
||||
public bool $isCreatingFirstMailbox = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->customDomain = Domain::where('is_active', true)->first()?->name ?? 'imail.app';
|
||||
@@ -49,6 +51,11 @@ class Mailbox extends Component
|
||||
} else {
|
||||
$this->currentMailboxId = $this->getActiveMailboxesProperty()->first()?->id;
|
||||
}
|
||||
|
||||
// Auto-create a random mailbox if none exist (first-time visitor)
|
||||
if ($this->getActiveMailboxesProperty()->isEmpty()) {
|
||||
$this->isCreatingFirstMailbox = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function getActiveMailboxesProperty()
|
||||
@@ -66,7 +73,11 @@ class Mailbox extends Component
|
||||
|
||||
public function getAvailableDomainsProperty()
|
||||
{
|
||||
return Domain::where('is_active', true)->where('is_archived', false)->get();
|
||||
return Domain::query()
|
||||
->where('is_active', true)
|
||||
->where('is_archived', false)
|
||||
->accessibleBy(auth()->user())
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +127,19 @@ class Mailbox extends Component
|
||||
->paginate(10);
|
||||
}
|
||||
|
||||
public function getUnreadCountProperty(): int
|
||||
{
|
||||
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||
|
||||
if (! $currentMailbox) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Email::where('recipient_email', $currentMailbox->address)
|
||||
->where('is_read', false)
|
||||
->count();
|
||||
}
|
||||
|
||||
public function selectEmail($id)
|
||||
{
|
||||
$this->selectedEmailId = $id;
|
||||
@@ -176,7 +200,11 @@ class Mailbox extends Component
|
||||
|
||||
public function createMailbox()
|
||||
{
|
||||
$domainModel = Domain::where('name', $this->customDomain)->first();
|
||||
$domainModel = Domain::where('name', $this->customDomain)
|
||||
->where('is_active', true)
|
||||
->accessibleBy(auth()->user())
|
||||
->first();
|
||||
|
||||
if (! $domainModel) {
|
||||
return;
|
||||
}
|
||||
@@ -204,6 +232,55 @@ class Mailbox extends Component
|
||||
Session::put('last_mailbox_id', $mailbox->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-create a random mailbox on a public domain.
|
||||
* Called when no mailboxes exist (first visit, or all deleted).
|
||||
*/
|
||||
public function autoCreateRandomMailbox(): void
|
||||
{
|
||||
$domain = Domain::query()
|
||||
->where('is_active', true)
|
||||
->where('is_archived', false)
|
||||
->accessibleBy(auth()->user())
|
||||
->whereJsonContains('allowed_types', 'public')
|
||||
->inRandomOrder()
|
||||
->first();
|
||||
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address = fake()->userName().'_'.rand(10, 99).'@'.$domain->name;
|
||||
|
||||
$mailbox = MailboxModel::create([
|
||||
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||
'domain_hash' => $domain->domain_hash,
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => Session::getId(),
|
||||
'address' => $address,
|
||||
'type' => 'public',
|
||||
'created_ip' => request()->ip(),
|
||||
'last_accessed_ip' => request()->ip(),
|
||||
'last_accessed_at' => now(),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$this->currentMailboxId = $mailbox->id;
|
||||
$this->isCreatingFirstMailbox = false;
|
||||
Session::put('current_mailbox_id', $mailbox->id);
|
||||
}
|
||||
|
||||
public function finishAutoCreation(): void
|
||||
{
|
||||
if ($this->getActiveMailboxesProperty()->isEmpty()) {
|
||||
$this->autoCreateRandomMailbox();
|
||||
}
|
||||
$this->isCreatingFirstMailbox = false;
|
||||
|
||||
// Ensure the component re-renders fully with the new data
|
||||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
public function deleteMailbox($id)
|
||||
{
|
||||
$mailbox = MailboxModel::find($id);
|
||||
@@ -216,6 +293,22 @@ class Mailbox extends Component
|
||||
$this->selectedEmailId = null;
|
||||
session(['current_mailbox_id' => $this->currentMailboxId]);
|
||||
}
|
||||
|
||||
// If all mailboxes deleted, auto-create a new random one
|
||||
// Use a direct query or re-fetch the property to avoid cache issues
|
||||
$hasActive = MailboxModel::query()
|
||||
->where(function ($query) {
|
||||
$query->where('session_id', Session::getId());
|
||||
if (auth()->check()) {
|
||||
$query->orWhere('user_id', auth()->id());
|
||||
}
|
||||
})
|
||||
->where('is_blocked', false)
|
||||
->exists();
|
||||
|
||||
if (! $hasActive) {
|
||||
$this->isCreatingFirstMailbox = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadEmail($id)
|
||||
@@ -296,6 +389,7 @@ class Mailbox extends Component
|
||||
return view('livewire.mailbox', [
|
||||
'emails' => $this->getEmailsProperty(),
|
||||
'currentMailbox' => $currentMailbox,
|
||||
'unreadCount' => $this->unread_count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -41,4 +42,27 @@ class Domain extends Model
|
||||
{
|
||||
return $this->hasMany(Mailbox::class, 'domain_hash', 'domain_hash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to domains accessible by the given user tier.
|
||||
* For guests (null user), only 'public' domains are returned.
|
||||
*
|
||||
* Uses MySQL's JSON_CONTAINS to check if the domain's `allowed_types`
|
||||
* array includes ANY of the user's allowed types.
|
||||
*
|
||||
* Usage:
|
||||
* Domain::accessibleBy(auth()->user())->get(); // logged-in
|
||||
* Domain::accessibleBy(null)->get(); // guest
|
||||
* Domain::accessibleBy($user)->where('is_active', true)->get();
|
||||
*/
|
||||
public function scopeAccessibleBy(Builder $query, ?User $user = null): Builder
|
||||
{
|
||||
$types = $user ? $user->allowedDomainTypes() : User::guestDomainTypes();
|
||||
|
||||
return $query->where(function (Builder $q) use ($types) {
|
||||
foreach ($types as $type) {
|
||||
$q->orWhereJsonContains('allowed_types', $type);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
|
||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
|
||||
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, TwoFactorAuthenticatable, HasRoles;
|
||||
use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -114,4 +115,102 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
||||
$this->has_email_authentication = $condition;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
// ─── Tier Checking Helpers ───────────────────────────────────
|
||||
|
||||
public function isFree(): bool
|
||||
{
|
||||
return $this->hasRole('free');
|
||||
}
|
||||
|
||||
public function isPro(): bool
|
||||
{
|
||||
return $this->hasRole('pro');
|
||||
}
|
||||
|
||||
public function isEnterprise(): bool
|
||||
{
|
||||
return $this->hasRole('enterprise');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the domain `allowed_types` values this user's tier can access.
|
||||
* Used by Domain::scopeAccessibleBy() to filter domains.
|
||||
*
|
||||
* Mapping:
|
||||
* free → ['public']
|
||||
* pro → ['public', 'custom', 'premium']
|
||||
* enterprise → ['public', 'custom', 'premium', 'private']
|
||||
* admin → ['public', 'custom', 'premium', 'private']
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function allowedDomainTypes(): array
|
||||
{
|
||||
return match (true) {
|
||||
$this->isAdmin() => ['public', 'custom', 'premium', 'private'],
|
||||
$this->isEnterprise() => ['public', 'custom', 'premium', 'private'],
|
||||
$this->isPro() => ['public', 'custom', 'premium'],
|
||||
default => ['public'], // free or no role
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain types accessible by guest (non-authenticated) users.
|
||||
* Called when auth()->user() is null.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function guestDomainTypes(): array
|
||||
{
|
||||
return ['public'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable tier label for sidebar display.
|
||||
* Returns UPPERCASE string like "FREE", "PRO", "ADMIN".
|
||||
*/
|
||||
public function tierLabel(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isAdmin() => 'ADMIN',
|
||||
$this->isEnterprise() => 'ENTERPRISE',
|
||||
$this->isPro() => 'PRO',
|
||||
default => 'FREE',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Eloquent Scopes ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scope to users with the 'free' Spatie role.
|
||||
* Usage: User::free()->get()
|
||||
*/
|
||||
public function scopeFree(Builder $query): Builder
|
||||
{
|
||||
return $query->role('free');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to users with the 'pro' Spatie role.
|
||||
* Usage: User::pro()->get()
|
||||
*/
|
||||
public function scopePro(Builder $query): Builder
|
||||
{
|
||||
return $query->role('pro');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to users with the 'enterprise' Spatie role.
|
||||
* Usage: User::enterprise()->get()
|
||||
*/
|
||||
public function scopeEnterprise(Builder $query): Builder
|
||||
{
|
||||
return $query->role('enterprise');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user