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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -41,4 +42,24 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function free(): static
|
||||
{
|
||||
return $this->afterCreating(fn (User $user) => $user->assignRole('free'));
|
||||
}
|
||||
|
||||
public function pro(): static
|
||||
{
|
||||
return $this->afterCreating(fn (User $user) => $user->assignRole('pro'));
|
||||
}
|
||||
|
||||
public function enterprise(): static
|
||||
{
|
||||
return $this->afterCreating(fn (User $user) => $user->assignRole('enterprise'));
|
||||
}
|
||||
|
||||
public function admin(): static
|
||||
{
|
||||
return $this->afterCreating(fn (User $user) => $user->assignRole('admin'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
@@ -14,13 +14,30 @@ class RoleSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$role = Role::findOrCreate(name: 'admin', guardName: 'web');
|
||||
Role::findOrCreate(name: 'user', guardName: 'web');
|
||||
// Clear Spatie's permission cache before seeding
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
|
||||
$permissionManageMails = Permission::findOrCreate(name: 'manage mails', guardName: 'web');
|
||||
$role->givePermissionTo($permissionManageMails);
|
||||
// --- Create Tier Roles ---
|
||||
$admin = Role::findOrCreate('admin', 'web');
|
||||
Role::findOrCreate('free', 'web');
|
||||
Role::findOrCreate('pro', 'web');
|
||||
Role::findOrCreate('enterprise', 'web');
|
||||
|
||||
$permissionManageFilamentPanel = Permission::findOrCreate(name: 'manage panels', guardName: 'web');
|
||||
$role->givePermissionTo($permissionManageFilamentPanel);
|
||||
// --- Permissions (admin-only) ---
|
||||
$manageMailsPerm = Permission::findOrCreate('manage mails', 'web');
|
||||
$managePanelsPerm = Permission::findOrCreate('manage panels', 'web');
|
||||
$admin->syncPermissions([$manageMailsPerm, $managePanelsPerm]);
|
||||
|
||||
// --- Migrate legacy 'user' role to 'free' ---
|
||||
$legacyRole = Role::where('name', 'user')->where('guard_name', 'web')->first();
|
||||
if ($legacyRole) {
|
||||
// Move all users with 'user' role to 'free'
|
||||
$usersWithLegacyRole = \App\Models\User::role('user')->get();
|
||||
foreach ($usersWithLegacyRole as $user) {
|
||||
$user->removeRole('user');
|
||||
$user->assignRole('free');
|
||||
}
|
||||
$legacyRole->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,16 @@
|
||||
<!-- Subtle Mesh Background -->
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(236,72,153,0.1),transparent)]"></div>
|
||||
|
||||
@php $gradientId = 'logo-gradient-' . Str::random(6); @endphp
|
||||
<!-- Stylized "Z" / Envelope Fold -->
|
||||
<svg class="{{ $dimensions['icon'] }} text-transparent" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<linearGradient id="{{ $gradientId }}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ec4899" />
|
||||
<stop offset="100%" style="stop-color:#be185d" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M4 19L20 5M4 19H14M20 5H10" stroke="url(#logo-gradient)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 19L20 5M4 19H14M20 5H10" stroke="url(#{{ $gradientId }})" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 5L20 19" stroke="white" stroke-opacity="0.1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -24,6 +24,51 @@
|
||||
x-init="$watch('selectedId', value => { if(value && window.innerWidth < 1024) mobileView = 'detail' }); $watch('mobileView', value => { if(value === 'list' && window.innerWidth < 1024) selectedId = null })"
|
||||
@resize.window="if (window.innerWidth >= 1280) sidebarOpen = true">
|
||||
|
||||
<!-- ═══ Cinematic Creation Overlay ═══ -->
|
||||
<div x-show="$wire.isCreatingFirstMailbox"
|
||||
x-transition:enter="transition ease-out duration-700"
|
||||
x-transition:enter-start="opacity-0 backdrop-blur-none"
|
||||
x-transition:enter-end="opacity-100 backdrop-blur-md"
|
||||
x-transition:leave="transition ease-in duration-500"
|
||||
x-transition:leave-start="opacity-100 backdrop-blur-md"
|
||||
x-transition:leave-end="opacity-0 backdrop-blur-none"
|
||||
class="fixed inset-0 z-[200] bg-zinc-950/90 flex flex-col items-center justify-center p-8 text-center"
|
||||
x-init="
|
||||
if ($wire.isCreatingFirstMailbox) { setTimeout(() => $wire.finishAutoCreation(), 1000); }
|
||||
$watch('$wire.isCreatingFirstMailbox', value => { if(value) setTimeout(() => $wire.finishAutoCreation(), 1000) });
|
||||
"
|
||||
style="display: none;">
|
||||
|
||||
<!-- Logo Animation -->
|
||||
<div class="mb-12 animate-pulse transition-all duration-1000 ease-in-out"
|
||||
x-bind:class="{ 'scale-110 opacity-100': $wire.isCreatingFirstMailbox, 'scale-90 opacity-0': !$wire.isCreatingFirstMailbox }">
|
||||
<x-bento.logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div class="w-24 h-24 relative mb-12 transform-gpu">
|
||||
<div class="absolute inset-0 border-4 border-pink-500/10 rounded-full transition-all duration-700"></div>
|
||||
<div class="absolute inset-0 border-4 border-pink-500 border-t-transparent animate-[spin_1.5s_cubic-bezier(0.4,0,0.2,1)_infinite] rounded-full shadow-[0_0_30px_rgba(236,72,153,0.3)]"></div>
|
||||
<div class="absolute inset-4 bg-pink-500/20 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-pink-500 animate-pulse transition-transform duration-700 ease-in-out hover:scale-110" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center max-w-sm space-y-4">
|
||||
<h2 class="text-xl font-black text-white uppercase tracking-[0.3em] font-mono animate-slide-up bg-clip-text text-transparent bg-gradient-to-r from-white via-zinc-200 to-zinc-400">Establishing Secure Identity</h2>
|
||||
<p class="text-[11px] text-zinc-500 font-bold uppercase tracking-widest leading-relaxed opacity-0 animate-slide-up [animation-delay:400ms]">
|
||||
Generating unique encryption keys and provisioning anonymous routing routes...
|
||||
</p>
|
||||
<div class="flex gap-1.5 opacity-0 animate-slide-up [animation-delay:800ms]">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-bounce [animation-delay:-0.3s]"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-bounce [animation-delay:-0.15s]"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-bounce"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Confirmation Modal -->
|
||||
<x-bento.confirm-modal />
|
||||
|
||||
@@ -66,7 +111,9 @@
|
||||
:class="$wire.activeFolder === 'inbox' ? 'bg-pink-500/10 text-pink-500 border border-pink-500/20 shadow-[0_0_20px_rgba(236,72,153,0.05)]' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>
|
||||
<span class="font-medium text-sm" :class="sidebarOpen ? 'opacity-100' : 'opacity-0 hidden'">Inbox</span>
|
||||
<span class="ml-auto text-[10px] font-bold px-1.5 py-0.5 rounded bg-pink-500/20 text-pink-500" :class="sidebarOpen ? 'opacity-100' : 'opacity-0 hidden'">1</span>
|
||||
@if($unreadCount > 0)
|
||||
<span class="ml-auto text-[10px] font-bold px-1.5 py-0.5 rounded bg-pink-500/20 text-pink-500" :class="sidebarOpen ? 'opacity-100' : 'opacity-0 hidden'">{{ $unreadCount }}</span>
|
||||
@endif
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -136,23 +183,25 @@
|
||||
</button>
|
||||
|
||||
<!-- Active List -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] px-3 mb-4">Your Sessions</h4>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-hide">
|
||||
@foreach($this->active_mailboxes as $mailbox)
|
||||
@if($mailbox->id !== $currentMailboxId)
|
||||
<button wire:click="switchMailbox({{ $mailbox->id }})"
|
||||
class="w-full p-3 rounded-xl bg-zinc-900/40 border border-white/5 text-left group hover:border-white/20 transition-all flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[10px] font-mono text-zinc-400 truncate group-hover:text-white transition-colors">{{ $mailbox->address }}</div>
|
||||
<div class="text-[9px] text-zinc-600 font-bold uppercase mt-1">{{ $mailbox->expires_at?->diffForHumans(['short' => true]) ?? 'Never' }}</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-zinc-700 group-hover:text-pink-500 translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
@endif
|
||||
@endforeach
|
||||
@if($this->active_mailboxes->where('id', '!=', $currentMailboxId)->count() > 0)
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] px-3 mb-4">Your Sessions</h4>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-hide">
|
||||
@foreach($this->active_mailboxes as $mailbox)
|
||||
@if($mailbox->id !== $currentMailboxId)
|
||||
<button wire:click="switchMailbox({{ $mailbox->id }})"
|
||||
class="w-full p-3 rounded-xl bg-zinc-900/40 border border-white/5 text-left group hover:border-white/20 transition-all flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[10px] font-mono text-zinc-400 truncate group-hover:text-white transition-colors">{{ $mailbox->address }}</div>
|
||||
<div class="text-[9px] text-zinc-600 font-bold uppercase mt-1">{{ $mailbox->expires_at?->diffForHumans(['short' => true]) ?? 'Never' }}</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-zinc-700 group-hover:text-pink-500 translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +227,7 @@
|
||||
</div>
|
||||
<div class="text-[9px] text-zinc-500 uppercase font-black tracking-tighter">
|
||||
@auth
|
||||
{{ auth()->user()->hasRole('admin') ? 'ADMIN USER' : (auth()->user()->hasRole('premium') ? 'PREMIUM USER' : 'FREE USER') }}
|
||||
{{ auth()->user()->tierLabel() }} USER
|
||||
@else
|
||||
GUEST USER
|
||||
@endauth
|
||||
@@ -211,7 +260,7 @@
|
||||
x-data="{ refreshing: false }"
|
||||
@click="refreshing = true; setTimeout(() => refreshing = false, 750)">
|
||||
<svg class="w-5 h-5"
|
||||
:class="{ 'animate-cinematic-spin': refreshing }"
|
||||
:class="{ 'animate-[spin_0.75s_ease-out_infinite]': refreshing }"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
@@ -220,7 +269,7 @@
|
||||
|
||||
<!-- List Content -->
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-white/5 scrollbar-hide" x-ref="listContainer">
|
||||
@foreach($emails as $email)
|
||||
@forelse($emails as $email)
|
||||
<div wire:key="email-{{ $email->id }}"
|
||||
@click="$wire.selectEmail({{ $email->id }}); mobileView = 'detail'"
|
||||
class="p-5 cursor-pointer transition-all relative group"
|
||||
@@ -257,33 +306,71 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@empty
|
||||
{{-- ═══ Cinematic Empty State ═══ --}}
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-8 text-center min-h-[400px]">
|
||||
{{-- Animated Envelope Icon --}}
|
||||
<div class="relative w-20 h-20 mb-8">
|
||||
<div class="absolute inset-0 bg-pink-500/10 rounded-2xl animate-pulse"></div>
|
||||
<div class="absolute inset-0 border border-pink-500/20 rounded-2xl"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-pink-500/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-bold text-zinc-400 mb-2 tracking-tight">No emails yet</h3>
|
||||
<p class="text-[11px] text-zinc-600 mb-6 max-w-[220px] leading-relaxed">
|
||||
Send an email to your address and it will appear here instantly
|
||||
</p>
|
||||
|
||||
{{-- Current mailbox address for easy reference --}}
|
||||
@if($currentMailbox)
|
||||
<button @click="navigator.clipboard.writeText('{{ $currentMailbox->address }}'); addToast('Address copied to clipboard', 'success')"
|
||||
class="px-4 py-2.5 rounded-xl bg-zinc-900 border border-white/5 text-[10px] font-mono text-zinc-400 break-all hover:border-pink-500/20 hover:text-white transition-all cursor-pointer group mb-6">
|
||||
<span class="group-hover:hidden">{{ $currentMailbox->address }}</span>
|
||||
<span class="hidden group-hover:inline text-pink-500 font-bold uppercase tracking-widest font-sans">Click to Copy</span>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Animated "Waiting" Indicator --}}
|
||||
<div class="flex items-center gap-2.5 text-zinc-600">
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-bold uppercase tracking-[0.2em] animate-pulse">Waiting for emails</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Sticky Pagination -->
|
||||
<div class="h-14 flex items-center justify-between px-4 border-t border-white/5 bg-zinc-950/40 backdrop-blur-xl shrink-0">
|
||||
<button wire:click="previousPage"
|
||||
@click="$refs.listContainer.scrollTop = 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/5 text-[10px] font-bold text-zinc-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:pointer-events-none uppercase tracking-widest"
|
||||
{{ $emails->onFirstPage() ? 'disabled' : '' }}>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
Prev
|
||||
</button>
|
||||
@if($emails->hasPages())
|
||||
<!-- Sticky Pagination -->
|
||||
<div class="h-14 flex items-center justify-between px-4 border-t border-white/5 bg-zinc-950/40 backdrop-blur-xl shrink-0">
|
||||
<button wire:click="previousPage"
|
||||
@click="$refs.listContainer.scrollTop = 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/5 text-[10px] font-bold text-zinc-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:pointer-events-none uppercase tracking-widest"
|
||||
{{ $emails->onFirstPage() ? 'disabled' : '' }}>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[10px] font-black text-white/90 uppercase tracking-[0.2em]">{{ $emails->currentPage() }}</span>
|
||||
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">/</span>
|
||||
<span class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{{ $emails->lastPage() }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[10px] font-black text-white/90 uppercase tracking-[0.2em]">{{ $emails->currentPage() }}</span>
|
||||
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">/</span>
|
||||
<span class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">{{ $emails->lastPage() }}</span>
|
||||
</div>
|
||||
|
||||
<button wire:click="nextPage"
|
||||
@click="$refs.listContainer.scrollTop = 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/5 text-[10px] font-bold text-zinc-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:pointer-events-none uppercase tracking-widest"
|
||||
{{ !$emails->hasMorePages() ? 'disabled' : '' }}>
|
||||
Next
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button wire:click="nextPage"
|
||||
@click="$refs.listContainer.scrollTop = 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/5 text-[10px] font-bold text-zinc-400 hover:text-white hover:bg-white/10 transition-all disabled:opacity-30 disabled:pointer-events-none uppercase tracking-widest"
|
||||
{{ !$emails->hasMorePages() ? 'disabled' : '' }}>
|
||||
Next
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<!-- QR Code Modal -->
|
||||
<div x-show="qrModal"
|
||||
@@ -694,13 +781,13 @@
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-cinematic-spin {
|
||||
animation: cinematic-spin 0.75s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes cinematic-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ test('registration screen can be rendered', function (): void {
|
||||
});
|
||||
|
||||
test('new users can register', function (): void {
|
||||
$this->seed(\Database\Seeders\RoleSeeder::class);
|
||||
|
||||
$response = Livewire::test(Register::class)
|
||||
->set('name', 'Test User')
|
||||
->set('email', 'test@example.com')
|
||||
|
||||
89
tests/Feature/UserTierTest.php
Normal file
89
tests/Feature/UserTierTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserTierTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Artisan::call('db:seed', ['--class' => 'RoleSeeder']);
|
||||
}
|
||||
|
||||
public function test_new_user_is_assigned_free_role_by_default()
|
||||
{
|
||||
\Livewire\Livewire::test(\App\Livewire\Auth\Register::class)
|
||||
->set('name', 'Test User')
|
||||
->set('email', 'test@example.com')
|
||||
->set('password', 'password')
|
||||
->set('password_confirmation', 'password')
|
||||
->call('register')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$user = User::where('email', 'test@example.com')->first();
|
||||
$this->assertNotNull($user, 'User was not created');
|
||||
$this->assertTrue($user->hasRole('free'));
|
||||
$this->assertEquals('FREE', $user->tierLabel());
|
||||
}
|
||||
|
||||
public function test_free_user_can_only_access_public_domains()
|
||||
{
|
||||
$publicDomain = Domain::factory()->create(['allowed_types' => ['public'], 'name' => 'public.com']);
|
||||
$premiumDomain = Domain::factory()->create(['allowed_types' => ['premium'], 'name' => 'premium.com']);
|
||||
|
||||
$user = User::factory()->free()->create();
|
||||
|
||||
$accessibleDomains = Domain::accessibleBy($user)->get();
|
||||
|
||||
$this->assertTrue($accessibleDomains->contains($publicDomain));
|
||||
$this->assertFalse($accessibleDomains->contains($premiumDomain));
|
||||
}
|
||||
|
||||
public function test_pro_user_can_access_public_and_premium_domains()
|
||||
{
|
||||
$publicDomain = Domain::factory()->create(['allowed_types' => ['public'], 'name' => 'public.com']);
|
||||
$premiumDomain = Domain::factory()->create(['allowed_types' => ['premium'], 'name' => 'premium.com']);
|
||||
$privateDomain = Domain::factory()->create(['allowed_types' => ['private'], 'name' => 'private.com']);
|
||||
|
||||
$user = User::factory()->pro()->create();
|
||||
|
||||
$accessibleDomains = Domain::accessibleBy($user)->get();
|
||||
|
||||
$this->assertTrue($accessibleDomains->contains($publicDomain));
|
||||
$this->assertTrue($accessibleDomains->contains($premiumDomain));
|
||||
$this->assertFalse($accessibleDomains->contains($privateDomain));
|
||||
}
|
||||
|
||||
public function test_enterprise_user_can_access_all_domains()
|
||||
{
|
||||
$publicDomain = Domain::factory()->create(['allowed_types' => ['public'], 'name' => 'public.com']);
|
||||
$privateDomain = Domain::factory()->create(['allowed_types' => ['private'], 'name' => 'private.com']);
|
||||
|
||||
$user = User::factory()->enterprise()->create();
|
||||
|
||||
$accessibleDomains = Domain::accessibleBy($user)->get();
|
||||
|
||||
$this->assertTrue($accessibleDomains->contains($publicDomain));
|
||||
$this->assertTrue($accessibleDomains->contains($privateDomain));
|
||||
}
|
||||
|
||||
public function test_guest_can_only_access_public_domains()
|
||||
{
|
||||
$publicDomain = Domain::factory()->create(['allowed_types' => ['public'], 'name' => 'public.com']);
|
||||
$premiumDomain = Domain::factory()->create(['allowed_types' => ['premium'], 'name' => 'premium.com']);
|
||||
|
||||
$accessibleDomains = Domain::accessibleBy(null)->get();
|
||||
|
||||
$this->assertTrue($accessibleDomains->contains($publicDomain));
|
||||
$this->assertFalse($accessibleDomains->contains($premiumDomain));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user