Auth: convert auth views and verification email to Nova layout
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal file
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal file
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user