Add authentication, a public landing page, and guest data preview
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.
This commit is contained in:
@@ -3,18 +3,20 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Site;
|
use App\Models\Site;
|
||||||
|
use App\Support\GuestPreview;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
||||||
class SiteController extends Controller
|
class SiteController extends Controller
|
||||||
{
|
{
|
||||||
public function show(Site $site): View
|
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()
|
$backlinks = GuestPreview::paginate(
|
||||||
->with(['site', 'sitePage'])
|
$site->inboundLinks()->with(['site', 'sitePage'])->latest(),
|
||||||
->latest()
|
50,
|
||||||
->paginate(50, ['*'], 'backlinks_page');
|
'backlinks_page'
|
||||||
|
);
|
||||||
|
|
||||||
$referringDomainsCount = $site->inboundLinks()->distinct()->count('site_id');
|
$referringDomainsCount = $site->inboundLinks()->distinct()->count('site_id');
|
||||||
$outboundLinksCount = $site->outboundLinks()->count();
|
$outboundLinksCount = $site->outboundLinks()->count();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Login extends Component
|
||||||
|
{
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
public string $password = '';
|
||||||
|
|
||||||
|
public bool $remember = false;
|
||||||
|
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Register extends Component
|
||||||
|
{
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
public string $password = '';
|
||||||
|
|
||||||
|
public string $password_confirmation = '';
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$validated = $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Livewire\Discover;
|
namespace App\Livewire\Discover;
|
||||||
|
|
||||||
use App\Models\Site;
|
use App\Models\Site;
|
||||||
|
use App\Support\GuestPreview;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
@@ -67,7 +68,7 @@ class Search extends Component
|
|||||||
if (! $hasQuery) {
|
if (! $hasQuery) {
|
||||||
$sites = new LengthAwarePaginator([], 0, 25, 1, ['path' => request()->url()]);
|
$sites = new LengthAwarePaginator([], 0, 25, 1, ['path' => request()->url()]);
|
||||||
} else {
|
} else {
|
||||||
$sites = Site::query()
|
$sites = GuestPreview::paginate(Site::query()
|
||||||
->when(
|
->when(
|
||||||
$keyword !== '',
|
$keyword !== '',
|
||||||
fn ($query) => $query->whereHas(
|
fn ($query) => $query->whereHas(
|
||||||
@@ -98,8 +99,7 @@ class Search extends Component
|
|||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
->orderByDesc('matching_pages_count')
|
->orderByDesc('matching_pages_count')
|
||||||
->orderBy('domain')
|
->orderBy('domain'));
|
||||||
->paginate(25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('livewire.discover.search', ['sites' => $sites, 'hasQuery' => $hasQuery]);
|
return view('livewire.discover.search', ['sites' => $sites, 'hasQuery' => $hasQuery]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Livewire\Sites;
|
namespace App\Livewire\Sites;
|
||||||
|
|
||||||
use App\Models\Site;
|
use App\Models\Site;
|
||||||
|
use App\Support\GuestPreview;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
@@ -24,14 +25,15 @@ class Index extends Component
|
|||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$sites = Site::query()
|
$sites = GuestPreview::paginate(
|
||||||
->withCount('pages')
|
Site::query()
|
||||||
->when(
|
->withCount('pages')
|
||||||
$this->search !== '',
|
->when(
|
||||||
fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%')
|
$this->search !== '',
|
||||||
)
|
fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%')
|
||||||
->orderBy('domain')
|
)
|
||||||
->paginate(25);
|
->orderBy('domain')
|
||||||
|
);
|
||||||
|
|
||||||
return view('livewire.sites.index', ['sites' => $sites]);
|
return view('livewire.sites.index', ['sites' => $sites]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps a listing to a small preview for unauthenticated visitors — always page 1,
|
||||||
|
* always a short page, regardless of what page/perPage the request asks for — while
|
||||||
|
* authenticated users get the real, full pagination. total() still reflects the true
|
||||||
|
* match count either way, so guests see "N results, sign in to see all" rather than a
|
||||||
|
* silently truncated list.
|
||||||
|
*/
|
||||||
|
class GuestPreview
|
||||||
|
{
|
||||||
|
public const LIMIT = 5;
|
||||||
|
|
||||||
|
public static function paginate(Builder|Relation $query, int $fullPerPage = 25, string $pageName = 'page'): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
if (self::isLimited()) {
|
||||||
|
return $query->paginate(self::LIMIT, ['*'], $pageName, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->paginate($fullPerPage, ['*'], $pageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isLimited(): bool
|
||||||
|
{
|
||||||
|
return ! auth()->check();
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-2
@@ -23,8 +23,12 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="mysql"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_HOST" value="192.168.0.155"/>
|
||||||
|
<env name="DB_PORT" value="3306"/>
|
||||||
|
<env name="DB_DATABASE" value="huginn_testing"/>
|
||||||
|
<env name="DB_USERNAME" value="admin"/>
|
||||||
|
<env name="DB_PASSWORD" value="admin"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@props(['paginator'])
|
||||||
|
|
||||||
|
@if ($paginator->total() > $paginator->count())
|
||||||
|
<p class="mt-4 rounded-md bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:bg-blue-950 dark:text-blue-300">
|
||||||
|
Showing a preview ({{ $paginator->count() }} of {{ number_format($paginator->total()) }} results).
|
||||||
|
<a href="{{ route('login') }}" class="font-medium underline">Sign in</a>
|
||||||
|
or
|
||||||
|
<a href="{{ route('register') }}" class="font-medium underline">create a free account</a>
|
||||||
|
to see everything.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
@@ -12,11 +12,23 @@
|
|||||||
<body class="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
|
<body class="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
|
||||||
<header class="border-b border-neutral-200 dark:border-neutral-800">
|
<header class="border-b border-neutral-200 dark:border-neutral-800">
|
||||||
<div class="mx-auto flex max-w-5xl items-center gap-6 px-4 py-4">
|
<div class="mx-auto flex max-w-5xl items-center gap-6 px-4 py-4">
|
||||||
<a href="{{ route('sites.index') }}" class="text-lg font-semibold">Huginn</a>
|
<a href="{{ route('home') }}" class="text-lg font-semibold">Huginn</a>
|
||||||
<nav class="flex gap-4 text-sm">
|
<nav class="flex flex-1 gap-4 text-sm">
|
||||||
<a href="{{ route('sites.index') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Sites</a>
|
<a href="{{ route('sites.index') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Sites</a>
|
||||||
<a href="{{ route('discover') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Discover</a>
|
<a href="{{ route('discover') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Discover</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<nav class="flex items-center gap-4 text-sm">
|
||||||
|
@auth
|
||||||
|
<span class="text-neutral-500">{{ auth()->user()->name }}</span>
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Log out</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Log in</a>
|
||||||
|
<a href="{{ route('register') }}" class="rounded-md bg-neutral-900 px-3 py-1.5 font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Sign up</a>
|
||||||
|
@endauth
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<x-layouts.app>
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-20 text-center">
|
||||||
|
<h1 class="text-4xl font-bold tracking-tight">Find out who you're really competing with</h1>
|
||||||
|
<p class="mt-4 text-lg text-neutral-600 dark:text-neutral-400">
|
||||||
|
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
|
||||||
|
<span class="whitespace-nowrap">.es</span> domain," or "which of my competitors run WordPress."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center gap-4">
|
||||||
|
@auth
|
||||||
|
<a href="{{ route('discover') }}" class="rounded-md bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Go to Discover</a>
|
||||||
|
<a href="{{ route('sites.index') }}" class="rounded-md border border-neutral-300 px-5 py-2.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900">Browse sites</a>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('register') }}" class="rounded-md bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Sign up free</a>
|
||||||
|
<a href="{{ route('login') }}" class="rounded-md border border-neutral-300 px-5 py-2.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900">Log in</a>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@guest
|
||||||
|
<p class="mt-10 text-sm text-neutral-500">
|
||||||
|
Browsing without an account shows a limited preview of results —
|
||||||
|
<a href="{{ route('register') }}" class="text-blue-600 hover:underline dark:text-blue-400">create a free account</a> to see everything.
|
||||||
|
</p>
|
||||||
|
@endguest
|
||||||
|
</div>
|
||||||
|
</x-layouts.app>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="mx-auto max-w-sm px-4 py-16">
|
||||||
|
<h1 class="text-2xl font-semibold">Log in</h1>
|
||||||
|
|
||||||
|
<form wire:submit="login" class="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
wire:model="email"
|
||||||
|
autofocus
|
||||||
|
autocomplete="username"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
@error('email') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
wire:model="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
@error('password') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
<input type="checkbox" wire:model="remember" class="rounded border-neutral-300 dark:border-neutral-700">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-sm text-neutral-500">
|
||||||
|
Don't have an account? <a href="{{ route('register') }}" class="text-blue-600 hover:underline dark:text-blue-400">Sign up</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<div class="mx-auto max-w-sm px-4 py-16">
|
||||||
|
<h1 class="text-2xl font-semibold">Create an account</h1>
|
||||||
|
|
||||||
|
<form wire:submit="register" class="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
wire:model="name"
|
||||||
|
autofocus
|
||||||
|
autocomplete="name"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
@error('name') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
wire:model="email"
|
||||||
|
autocomplete="username"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
@error('email') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
wire:model="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
@error('password') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirmation" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password_confirmation"
|
||||||
|
wire:model="password_confirmation"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6 text-sm text-neutral-500">
|
||||||
|
Already have an account? <a href="{{ route('login') }}" class="text-blue-600 hover:underline dark:text-blue-400">Log in</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -69,6 +69,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{{ $sites->links() }}
|
@auth
|
||||||
|
{{ $sites->links() }}
|
||||||
|
@else
|
||||||
|
@if ($hasQuery)
|
||||||
|
<x-guest-preview-notice :paginator="$sites" />
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{{ $sites->links() }}
|
@auth
|
||||||
|
{{ $sites->links() }}
|
||||||
|
@else
|
||||||
|
<x-guest-preview-notice :paginator="$sites" />
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,7 +62,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{{ $pages->links() }}
|
@auth
|
||||||
|
{{ $pages->links() }}
|
||||||
|
@else
|
||||||
|
<x-guest-preview-notice :paginator="$pages" />
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mt-10 text-lg font-medium">
|
<h2 class="mt-10 text-lg font-medium">
|
||||||
@@ -127,7 +131,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{{ $backlinks->links() }}
|
@auth
|
||||||
|
{{ $backlinks->links() }}
|
||||||
|
@else
|
||||||
|
<x-guest-preview-notice :paginator="$backlinks" />
|
||||||
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.app>
|
</x-layouts.app>
|
||||||
|
|||||||
+18
-1
@@ -1,10 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\SiteController;
|
use App\Http\Controllers\SiteController;
|
||||||
|
use App\Livewire\Auth\Login;
|
||||||
|
use App\Livewire\Auth\Register;
|
||||||
use App\Livewire\Discover\Search as DiscoverSearch;
|
use App\Livewire\Discover\Search as DiscoverSearch;
|
||||||
use App\Livewire\Sites\Index as SitesIndex;
|
use App\Livewire\Sites\Index as SitesIndex;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', SitesIndex::class)->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('/sites/{site}', [SiteController::class, 'show'])->name('sites.show');
|
||||||
Route::get('/discover', DiscoverSearch::class)->name('discover');
|
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');
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Auth\Login;
|
||||||
|
use App\Livewire\Auth\Register;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthFlowTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_a_user_can_register(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Register::class)
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GuestPreviewTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function seedSites(int $count): void
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
Site::create([
|
||||||
|
'collection' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user