Auth: convert auth views and verification email to Nova layout

This commit is contained in:
2026-02-21 07:37:08 +01:00
parent 93b009d42a
commit 795c7a835f
117 changed files with 5385 additions and 1291 deletions

View File

@@ -3,23 +3,46 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\RegistrationVerificationMail;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use App\Services\Security\RecaptchaVerifier;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
public function __construct(
private readonly RecaptchaVerifier $recaptchaVerifier
)
{
}
/**
* Display the registration view.
*/
public function create(): View
public function create(Request $request): View
{
return view('auth.register');
return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
]);
}
public function notice(Request $request): View
{
$email = (string) session('registration_email', '');
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
return view('auth.register-notice', [
'email' => $email,
'resendSeconds' => $remaining,
]);
}
/**
@@ -29,22 +52,127 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'website' => ['nullable', 'max:0'],
]);
if ($this->recaptchaVerifier->isEnabled()) {
$request->validate([
'g-recaptcha-response' => ['required', 'string'],
]);
$verified = $this->recaptchaVerifier->verify(
(string) $request->input('g-recaptcha-response', ''),
$request->ip()
);
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'reCAPTCHA verification failed. Please try again.']);
}
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'username' => null,
'name' => Str::before((string) $validated['email'], '@'),
'email' => $validated['email'],
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
]);
event(new Registered($user));
$token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Auth::login($user);
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
return redirect(route('dashboard', absolute: false));
$cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown((string) $validated['email'], $cooldown);
return redirect(route('register.notice', absolute: false))
->with('status', 'Verification email sent. Please check your inbox.')
->with('registration_email', (string) $validated['email']);
}
public function resendVerification(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$email = (string) $validated['email'];
$remaining = $this->resendRemainingSeconds($email);
if ($remaining > 0) {
return back()
->with('registration_email', $email)
->withErrors(['email' => "Please wait {$remaining} seconds before resending."]);
}
$user = User::query()
->where('email', $email)
->whereNull('email_verified_at')
->where('onboarding_step', 'email')
->first();
if (! $user) {
return back()
->with('registration_email', $email)
->withErrors(['email' => 'No pending verification found for this email.']);
}
DB::table('user_verification_tokens')->where('user_id', $user->id)->delete();
$token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
$cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown($email, $cooldown);
return redirect(route('register.notice', absolute: false))
->with('registration_email', $email)
->with('status', 'Verification email resent. Please check your inbox.');
}
private function resendCooldownSeconds(): int
{
return max(5, (int) config('antispam.register.resend_cooldown_seconds', 60));
}
private function resendCooldownCacheKey(string $email): string
{
return 'register:resend:cooldown:' . sha1(strtolower(trim($email)));
}
private function setResendCooldown(string $email, int $seconds): void
{
$until = CarbonImmutable::now()->addSeconds($seconds)->timestamp;
Cache::put($this->resendCooldownCacheKey($email), $until, $seconds + 5);
}
private function resendRemainingSeconds(string $email): int
{
$until = (int) Cache::get($this->resendCooldownCacheKey($email), 0);
if ($until <= 0) {
return 0;
}
return max(0, $until - time());
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RegistrationVerificationController extends Controller
{
public function __invoke(string $token): RedirectResponse
{
$record = DB::table('user_verification_tokens')
->where('token', $token)
->first();
if (! $record) {
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
if (now()->greaterThan($record->expires_at)) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link has expired.']);
}
$user = User::query()->find((int) $record->user_id);
if (! $user) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
$user->forceFill([
'email_verified_at' => $user->email_verified_at ?? now(),
'onboarding_step' => 'verified',
'is_active' => true,
])->save();
DB::table('user_verification_tokens')
->where('id', $record->id)
->delete();
Auth::login($user);
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class SetupPasswordController extends Controller
{
public function create(Request $request): View
{
return view('auth.setup-password', [
'email' => (string) ($request->user()?->email ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'password' => [
'required',
'string',
'min:10',
'regex:/\d/',
'regex:/[^\w\s]/',
'confirmed',
],
], [
'password.min' => 'Your password must be at least 10 characters.',
'password.regex' => 'Your password must include at least one number and one symbol.',
'password.confirmed' => 'Password confirmation does not match.',
]);
$request->user()->forceFill([
'password' => Hash::make((string) $validated['password']),
'onboarding_step' => 'password',
'needs_password_reset' => false,
])->save();
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
class SetupUsernameController extends Controller
{
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
{
}
public function create(Request $request): View
{
return view('auth.setup-username', [
'username' => (string) ($request->user()?->username ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
$request->merge(['username' => $normalized]);
$validated = $request->validate([
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
'username.min' => 'Username must be at least 3 characters.',
'username.max' => 'Username must be at most 20 characters.',
]);
$candidate = (string) $validated['username'];
$user = $request->user();
$similar = UsernamePolicy::similarReserved($candidate);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
'current_username' => (string) ($user->username ?? ''),
]);
return back()
->withInput()
->with('status', 'Your request has been submitted for manual username review.')
->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
DB::transaction(function () use ($user, $candidate): void {
$oldUsername = (string) ($user->username ?? '');
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => strtolower($oldUsername),
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => strtolower($oldUsername)],
[
'new_username' => strtolower($candidate),
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
$user->forceFill([
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
])->save();
});
return redirect('/@' . strtolower($candidate));
}
}