diff --git a/app/Livewire/Auth/Register.php b/app/Livewire/Auth/Register.php index 8541536..24ee812 100644 --- a/app/Livewire/Auth/Register.php +++ b/app/Livewire/Auth/Register.php @@ -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); diff --git a/app/Livewire/Mailbox.php b/app/Livewire/Mailbox.php index ef0257b..82fff52 100644 --- a/app/Livewire/Mailbox.php +++ b/app/Livewire/Mailbox.php @@ -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, ]); } } diff --git a/app/Models/Domain.php b/app/Models/Domain.php index 5805ac7..0a75fb8 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -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); + } + }); + } } diff --git a/app/Models/User.php b/app/Models/User.php index cbe21b6..0d911b0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 + */ + 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 + */ + 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'); + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..f91bcd8 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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')); + } } diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index cdd6001..ac9d1a8 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -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(); + } } } diff --git a/resources/views/components/bento/logo.blade.php b/resources/views/components/bento/logo.blade.php index 53c893e..082563f 100644 --- a/resources/views/components/bento/logo.blade.php +++ b/resources/views/components/bento/logo.blade.php @@ -24,15 +24,16 @@
+ @php $gradientId = 'logo-gradient-' . Str::random(6); @endphp - + - + diff --git a/resources/views/livewire/mailbox.blade.php b/resources/views/livewire/mailbox.blade.php index 8fde20f..0e4969c 100644 --- a/resources/views/livewire/mailbox.blade.php +++ b/resources/views/livewire/mailbox.blade.php @@ -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"> + + + @@ -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'"> Inbox - 1 + @if($unreadCount > 0) + {{ $unreadCount }} + @endif @@ -136,23 +183,25 @@ -
-

Your Sessions

-
- @foreach($this->active_mailboxes as $mailbox) - @if($mailbox->id !== $currentMailboxId) - - @endif - @endforeach + @if($this->active_mailboxes->where('id', '!=', $currentMailboxId)->count() > 0) +
+

Your Sessions

+
+ @foreach($this->active_mailboxes as $mailbox) + @if($mailbox->id !== $currentMailboxId) + + @endif + @endforeach +
-
+ @endif
@@ -178,7 +227,7 @@
@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)"> @@ -220,7 +269,7 @@
- @foreach($emails as $email) + @forelse($emails as $email)
@endif
- @endforeach + @empty + {{-- ═══ Cinematic Empty State ═══ --}} +
+ {{-- Animated Envelope Icon --}} +
+
+
+
+ + + +
+
+ +

No emails yet

+

+ Send an email to your address and it will appear here instantly +

+ + {{-- Current mailbox address for easy reference --}} + @if($currentMailbox) + + @endif + + {{-- Animated "Waiting" Indicator --}} +
+ + + + Waiting for emails +
+
+ @endforelse
- -
- - -
- {{ $emails->currentPage() }} - / - {{ $emails->lastPage() }} + @if($emails->hasPages()) + +
+ + +
+ {{ $emails->currentPage() }} + / + {{ $emails->lastPage() }} +
+ +
- - -
+ @endif
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index ba2cb20..b92e440 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -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') diff --git a/tests/Feature/UserTierTest.php b/tests/Feature/UserTierTest.php new file mode 100644 index 0000000..de14418 --- /dev/null +++ b/tests/Feature/UserTierTest.php @@ -0,0 +1,89 @@ + '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)); + } +}