From f5c91297742bb28f2e36b1692caab7b2542cfbb3 Mon Sep 17 00:00:00 2001 From: m105 Date: Sat, 25 Jul 2026 15:22:42 +0300 Subject: [PATCH] Add authentication, a public landing page, and guest data preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled login/register/logout on Laravel's built-in Auth facade rather than Breeze/Jetstream, which scaffold Tailwind v3 config and would fight our existing Tailwind v4 CSS-first setup. Login/Register are Livewire components; logout is a plain POST route (auth-gated, CSRF-protected). Routing: "/" is now a public landing page describing the project; the sites list moved from "/" to "/sites" to make room for it. Data gating: App\Support\GuestPreview::paginate() forces page 1 and a 5-row page size for unauthenticated visitors on Sites, Discover, and a site's Pages/Backlinks tables, while total() still reflects the real match count — authenticated users get the existing full pagination unchanged. A shared guest-preview-notice component shows "N of M results, sign in to see all" and only renders when something is actually being hidden. Testing: pointed the test suite at a dedicated MySQL database (huginn_testing) instead of the default in-memory SQLite, since the existing fulltext index migration isn't SQLite-compatible — this app is MySQL-specific by now regardless. Added AuthFlowTest (register, login, wrong-password rejection, logout) and GuestPreviewTest (guest capped to a preview, authenticated sees everything), all passing. Also fixed a real bug the tests caught: request()->session() isn't reliably attached during Livewire's in-process test action calls; switched Login/Register to the session() helper, which resolves the session store directly and works in both contexts. --- app/Http/Controllers/SiteController.php | 12 ++-- app/Livewire/Auth/Login.php | 42 ++++++++++++ app/Livewire/Auth/Register.php | 48 ++++++++++++++ app/Livewire/Discover/Search.php | 6 +- app/Livewire/Sites/Index.php | 18 ++--- app/Support/GuestPreview.php | 33 ++++++++++ phpunit.xml | 8 ++- .../components/guest-preview-notice.blade.php | 11 ++++ .../views/components/layouts/app.blade.php | 16 ++++- resources/views/home.blade.php | 27 ++++++++ resources/views/livewire/auth/login.blade.php | 46 +++++++++++++ .../views/livewire/auth/register.blade.php | 64 ++++++++++++++++++ .../views/livewire/discover/search.blade.php | 8 ++- .../views/livewire/sites/index.blade.php | 6 +- resources/views/sites/show.blade.php | 12 +++- routes/web.php | 19 +++++- tests/Feature/AuthFlowTest.php | 66 +++++++++++++++++++ tests/Feature/GuestPreviewTest.php | 44 +++++++++++++ 18 files changed, 461 insertions(+), 25 deletions(-) create mode 100644 app/Livewire/Auth/Login.php create mode 100644 app/Livewire/Auth/Register.php create mode 100644 app/Support/GuestPreview.php create mode 100644 resources/views/components/guest-preview-notice.blade.php create mode 100644 resources/views/home.blade.php create mode 100644 resources/views/livewire/auth/login.blade.php create mode 100644 resources/views/livewire/auth/register.blade.php create mode 100644 tests/Feature/AuthFlowTest.php create mode 100644 tests/Feature/GuestPreviewTest.php diff --git a/app/Http/Controllers/SiteController.php b/app/Http/Controllers/SiteController.php index 683038c..843db21 100644 --- a/app/Http/Controllers/SiteController.php +++ b/app/Http/Controllers/SiteController.php @@ -3,18 +3,20 @@ namespace App\Http\Controllers; use App\Models\Site; +use App\Support\GuestPreview; use Illuminate\Contracts\View\View; class SiteController extends Controller { public function show(Site $site): View { - $pages = $site->pages()->orderBy('url')->paginate(50, ['*'], 'pages_page'); + $pages = GuestPreview::paginate($site->pages()->orderBy('url'), 50, 'pages_page'); - $backlinks = $site->inboundLinks() - ->with(['site', 'sitePage']) - ->latest() - ->paginate(50, ['*'], 'backlinks_page'); + $backlinks = GuestPreview::paginate( + $site->inboundLinks()->with(['site', 'sitePage'])->latest(), + 50, + 'backlinks_page' + ); $referringDomainsCount = $site->inboundLinks()->distinct()->count('site_id'); $outboundLinksCount = $site->outboundLinks()->count(); diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php new file mode 100644 index 0000000..ed557f4 --- /dev/null +++ b/app/Livewire/Auth/Login.php @@ -0,0 +1,42 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { + throw ValidationException::withMessages([ + 'email' => 'These credentials do not match our records.', + ]); + } + + session()->regenerate(); + + $this->redirect(route('sites.index'), navigate: false); + } + + public function render(): View + { + return view('livewire.auth.login'); + } +} diff --git a/app/Livewire/Auth/Register.php b/app/Livewire/Auth/Register.php new file mode 100644 index 0000000..d09c8c4 --- /dev/null +++ b/app/Livewire/Auth/Register.php @@ -0,0 +1,48 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + ]); + + Auth::login($user); + + session()->regenerate(); + + $this->redirect(route('sites.index'), navigate: false); + } + + public function render(): View + { + return view('livewire.auth.register'); + } +} diff --git a/app/Livewire/Discover/Search.php b/app/Livewire/Discover/Search.php index 3f50e60..e303a13 100644 --- a/app/Livewire/Discover/Search.php +++ b/app/Livewire/Discover/Search.php @@ -3,6 +3,7 @@ namespace App\Livewire\Discover; use App\Models\Site; +use App\Support\GuestPreview; use Illuminate\Contracts\View\View; use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Attributes\Layout; @@ -67,7 +68,7 @@ class Search extends Component if (! $hasQuery) { $sites = new LengthAwarePaginator([], 0, 25, 1, ['path' => request()->url()]); } else { - $sites = Site::query() + $sites = GuestPreview::paginate(Site::query() ->when( $keyword !== '', fn ($query) => $query->whereHas( @@ -98,8 +99,7 @@ class Search extends Component } }]) ->orderByDesc('matching_pages_count') - ->orderBy('domain') - ->paginate(25); + ->orderBy('domain')); } return view('livewire.discover.search', ['sites' => $sites, 'hasQuery' => $hasQuery]); diff --git a/app/Livewire/Sites/Index.php b/app/Livewire/Sites/Index.php index 36251a7..a0a75da 100644 --- a/app/Livewire/Sites/Index.php +++ b/app/Livewire/Sites/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Sites; use App\Models\Site; +use App\Support\GuestPreview; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; @@ -24,14 +25,15 @@ class Index extends Component public function render(): View { - $sites = Site::query() - ->withCount('pages') - ->when( - $this->search !== '', - fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%') - ) - ->orderBy('domain') - ->paginate(25); + $sites = GuestPreview::paginate( + Site::query() + ->withCount('pages') + ->when( + $this->search !== '', + fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%') + ) + ->orderBy('domain') + ); return view('livewire.sites.index', ['sites' => $sites]); } diff --git a/app/Support/GuestPreview.php b/app/Support/GuestPreview.php new file mode 100644 index 0000000..1ecbe22 --- /dev/null +++ b/app/Support/GuestPreview.php @@ -0,0 +1,33 @@ +paginate(self::LIMIT, ['*'], $pageName, 1); + } + + return $query->paginate($fullPerPage, ['*'], $pageName); + } + + public static function isLimited(): bool + { + return ! auth()->check(); + } +} diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..f896766 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,8 +23,12 @@ - - + + + + + + diff --git a/resources/views/components/guest-preview-notice.blade.php b/resources/views/components/guest-preview-notice.blade.php new file mode 100644 index 0000000..62d7abd --- /dev/null +++ b/resources/views/components/guest-preview-notice.blade.php @@ -0,0 +1,11 @@ +@props(['paginator']) + +@if ($paginator->total() > $paginator->count()) +

+ Showing a preview ({{ $paginator->count() }} of {{ number_format($paginator->total()) }} results). + Sign in + or + create a free account + to see everything. +

+@endif diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index a2bcd2f..3c0c134 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -12,11 +12,23 @@
- Huginn -
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php new file mode 100644 index 0000000..37173e1 --- /dev/null +++ b/resources/views/home.blade.php @@ -0,0 +1,27 @@ + +
+

Find out who you're really competing with

+

+ Huginn indexes the web using Common Crawl's public archive and helps you find competitors + by product, location, and even the platform they're built on — e.g. "who sells honey with a + .es domain," or "which of my competitors run WordPress." +

+ +
+ @auth + Go to Discover + Browse sites + @else + Sign up free + Log in + @endauth +
+ + @guest +

+ Browsing without an account shows a limited preview of results — + create a free account to see everything. +

+ @endguest +
+
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php new file mode 100644 index 0000000..0dbda45 --- /dev/null +++ b/resources/views/livewire/auth/login.blade.php @@ -0,0 +1,46 @@ +
+

Log in

+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ + + + +
+ +

+ Don't have an account? Sign up. +

+
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php new file mode 100644 index 0000000..7978d68 --- /dev/null +++ b/resources/views/livewire/auth/register.blade.php @@ -0,0 +1,64 @@ +
+

Create an account

+ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @error('password')

{{ $message }}

@enderror +
+ +
+ + +
+ + +
+ +

+ Already have an account? Log in. +

+
diff --git a/resources/views/livewire/discover/search.blade.php b/resources/views/livewire/discover/search.blade.php index dc961a5..45b0171 100644 --- a/resources/views/livewire/discover/search.blade.php +++ b/resources/views/livewire/discover/search.blade.php @@ -69,6 +69,12 @@
- {{ $sites->links() }} + @auth + {{ $sites->links() }} + @else + @if ($hasQuery) + + @endif + @endauth
diff --git a/resources/views/livewire/sites/index.blade.php b/resources/views/livewire/sites/index.blade.php index 215d65d..cdbba69 100644 --- a/resources/views/livewire/sites/index.blade.php +++ b/resources/views/livewire/sites/index.blade.php @@ -41,6 +41,10 @@
- {{ $sites->links() }} + @auth + {{ $sites->links() }} + @else + + @endauth
diff --git a/resources/views/sites/show.blade.php b/resources/views/sites/show.blade.php index 0a827d9..b543de2 100644 --- a/resources/views/sites/show.blade.php +++ b/resources/views/sites/show.blade.php @@ -62,7 +62,11 @@
- {{ $pages->links() }} + @auth + {{ $pages->links() }} + @else + + @endauth

@@ -127,7 +131,11 @@
- {{ $backlinks->links() }} + @auth + {{ $backlinks->links() }} + @else + + @endauth
diff --git a/routes/web.php b/routes/web.php index b2ca880..d5f7ff2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,27 @@ name('sites.index'); +Route::view('/', 'home')->name('home'); +Route::get('/sites', SitesIndex::class)->name('sites.index'); Route::get('/sites/{site}', [SiteController::class, 'show'])->name('sites.show'); Route::get('/discover', DiscoverSearch::class)->name('discover'); + +Route::middleware('guest')->group(function () { + Route::get('/login', Login::class)->name('login'); + Route::get('/register', Register::class)->name('register'); +}); + +Route::post('/logout', function () { + Auth::logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect()->route('home'); +})->middleware('auth')->name('logout'); diff --git a/tests/Feature/AuthFlowTest.php b/tests/Feature/AuthFlowTest.php new file mode 100644 index 0000000..b6fa474 --- /dev/null +++ b/tests/Feature/AuthFlowTest.php @@ -0,0 +1,66 @@ +set('name', 'Test User') + ->set('email', 'testuser@example.com') + ->set('password', 'correct-horse-battery-staple') + ->set('password_confirmation', 'correct-horse-battery-staple') + ->call('register') + ->assertRedirect(route('sites.index')); + + $this->assertAuthenticated(); + $this->assertDatabaseHas('users', ['email' => 'testuser@example.com']); + } + + public function test_a_user_can_log_in(): void + { + $user = User::factory()->create(['password' => 'correct-horse-battery-staple']); + + Livewire::test(Login::class) + ->set('email', $user->email) + ->set('password', 'correct-horse-battery-staple') + ->call('login') + ->assertRedirect(route('sites.index')); + + $this->assertAuthenticatedAs($user); + } + + public function test_login_fails_with_wrong_password(): void + { + $user = User::factory()->create(['password' => 'correct-horse-battery-staple']); + + Livewire::test(Login::class) + ->set('email', $user->email) + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors('email'); + + $this->assertGuest(); + } + + public function test_a_user_can_log_out(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('logout')) + ->assertRedirect(route('home')); + + $this->assertGuest(); + } +} diff --git a/tests/Feature/GuestPreviewTest.php b/tests/Feature/GuestPreviewTest.php new file mode 100644 index 0000000..26dc99b --- /dev/null +++ b/tests/Feature/GuestPreviewTest.php @@ -0,0 +1,44 @@ + 'CC-MAIN-TEST', + 'domain' => "example{$i}.com", + 'url' => "https://example{$i}.com/", + ]); + } + } + + public function test_guest_sees_only_a_preview_of_sites(): void + { + $this->seedSites(8); + + $response = $this->get(route('sites.index')); + + $response->assertOk(); + $response->assertSeeText('Showing a preview (5 of 8 results)'); + } + + public function test_authenticated_user_sees_all_sites(): void + { + $this->seedSites(8); + + $response = $this->actingAs(User::factory()->create())->get(route('sites.index')); + + $response->assertOk(); + $response->assertDontSeeText('Showing a preview'); + } +}