feat(auth): add public registration for the first account#41
Conversation
There was a problem hiding this comment.
Pull request overview
This PR re-enables Laravel Fortify registration and introduces an admin user-impersonation flow, adding UI and tests to support both capabilities.
Changes:
- Enable Fortify registration, add a custom registration page, and create users with
first_name/last_name. - Add admin impersonation routes/controller plus UI actions to impersonate users and (attempt to) stop impersonation.
- Add Pest feature tests for registration and impersonation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
config/fortify.php |
Enables Fortify registration feature. |
app/Providers/FortifyServiceProvider.php |
Wires Fortify registration view + user creation action. |
app/Actions/Fortify/CreateNewUser.php |
Implements Fortify user creation with first/last name validation. |
resources/views/auth/register.blade.php |
Adds registration page using shared shell/components. |
routes/web.php |
Adds admin impersonation routes under /admin. |
app/Http/Controllers/Admin/ImpersonationController.php |
Implements impersonation start/stop behavior using session state. |
resources/views/admin/users/index.blade.php |
Adds “Impersonate” action per user row. |
resources/views/admin/dashboard.blade.php |
Adds “Stop impersonation” button (conditional on session). |
tests/Feature/Auth/RegistrationTest.php |
Tests registration page rendering and successful account creation. |
tests/Feature/Admin/ImpersonationTest.php |
Tests admin impersonation and non-admin blocking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 'features' => [ | ||
| Features::registration(), | ||
| Features::resetPasswords(), | ||
| // Features::emailVerification(), |
There was a problem hiding this comment.
Registration is enabled globally via Fortify features, but this PR/issue scope is “public registration for the first account”. As-is, anyone can keep registering new accounts after the first user exists. Consider gating registration (GET+POST) once a user exists (e.g., deny/404 the register view and have the user creation action fail when User::query()->exists()).
| Fortify::createUsersUsing(CreateNewUser::class); | ||
| Fortify::resetUserPasswordsUsing(ResetUserPassword::class); | ||
| Fortify::loginView(fn () => view('auth.login')); | ||
| Fortify::registerView(fn () => view('auth.register')); | ||
| Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password')); |
There was a problem hiding this comment.
The register view is always returned. If registration should only be available until the first account is created, the view should be denied once any users exist (otherwise the UI continues to advertise an open registration page).
| public function create(array $input): User | ||
| { | ||
| Validator::make($input, [ | ||
| 'first_name' => ['required', 'string', 'max:255'], | ||
| 'last_name' => ['required', 'string', 'max:255'], | ||
| 'email' => [ | ||
| 'required', | ||
| 'string', | ||
| 'email', | ||
| 'max:255', | ||
| Rule::unique(User::class), | ||
| ], | ||
| 'password' => $this->passwordRules(), | ||
| ])->validate(); |
There was a problem hiding this comment.
User creation currently has no guard to prevent additional registrations after the first account exists. If registration should only bootstrap the initial account, add a server-side check here (not just in the GET view) so POST /register can’t create more users once the system is initialized.
| Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy'); | ||
| }); |
There was a problem hiding this comment.
These impersonation routes are inside the admin middleware group (auth + can:access-admin). After impersonating a non-admin user, the session won’t be able to hit this DELETE route to stop impersonation because the current user will fail the admin gate. Consider moving the “stop impersonation” route outside the admin gate (or adjusting middleware) so it’s callable while impersonating.
| Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy'); | |
| }); | |
| }); | |
| Route::delete('/admin/impersonation', [ImpersonationController::class, 'destroy']) | |
| ->middleware(['auth']) | |
| ->name('admin.impersonation.destroy'); |
| public function destroy(Request $request): RedirectResponse | ||
| { | ||
| Gate::authorize('access-admin'); | ||
|
|
||
| $impersonatorId = $request->session()->pull('impersonator_id'); | ||
|
|
||
| if (! is_string($impersonatorId) && ! is_int($impersonatorId)) { | ||
| return redirect()->route('admin.users.index'); | ||
| } |
There was a problem hiding this comment.
destroy() authorizes access-admin, but when an admin is impersonating a regular user the current authenticated user will not be an admin, so this will 403 and prevent ending impersonation. Authorization here should be based on the stored impersonator session state (and/or allow the impersonated session to revert), not on the current user being an admin.
| @if (session()->has('impersonator_id')) | ||
| <form method="POST" action="{{ route('admin.impersonation.destroy') }}"> | ||
| @csrf | ||
| @method('DELETE') | ||
|
|
||
| <x-ui.button variant="secondary" type="submit">Stop impersonation</x-ui.button> | ||
| </form> | ||
| @endif |
There was a problem hiding this comment.
The “Stop impersonation” control is only rendered on the admin dashboard, but an impersonated non-admin user can’t access admin routes (and the stop route is also under the admin gate). This leaves no UI path to end an impersonation session. Consider rendering a global banner/action in the main shell (or otherwise exposing the stop action somewhere reachable while impersonating).
| $this->actingAs($admin) | ||
| ->post(route('admin.users.impersonate', $user)) | ||
| ->assertRedirect(route('home')); | ||
|
|
||
| $this->assertAuthenticatedAs($user); | ||
|
|
||
| $this->actingAs($admin) | ||
| ->delete(route('admin.impersonation.destroy')) | ||
| ->assertRedirect(route('admin.users.index')); | ||
|
|
||
| $this->assertAuthenticatedAs($admin); | ||
| }); |
There was a problem hiding this comment.
This test doesn’t reflect the real impersonation flow: after POSTing to impersonate, the browser session is authenticated as the target user, but the test switches back to actingAs($admin) before calling the stop route. That masks the bug where stopping impersonation is inaccessible/unauthorized while impersonating. Consider issuing the DELETE using the same session (no re-auth), and asserting it returns to the admin user.
|
|
||
| expect(User::where('email', 'taylor@example.com')->exists())->toBeTrue(); | ||
| $this->assertAuthenticated(); | ||
| }); |
There was a problem hiding this comment.
Given the stated scope (“public registration for the first account”), there’s no coverage that registration is blocked once a user already exists. Consider adding a test that creates an initial user and then asserts GET/POST to register is denied (e.g., 404/403 or redirect), and that no additional user is created.
| }); | |
| }); | |
| it('does not show the registration page once a user exists', function (): void { | |
| User::factory()->create(); | |
| $response = $this->get(route('register')); | |
| expect(in_array($response->getStatusCode(), [302, 403, 404], true))->toBeTrue(); | |
| expect(User::count())->toBe(1); | |
| }); | |
| it('does not create a new user account once a user exists', function (): void { | |
| User::factory()->create([ | |
| 'email' => 'existing@example.com', | |
| ]); | |
| $response = $this->post(route('register'), [ | |
| 'first_name' => 'Taylor', | |
| 'last_name' => 'Otwell', | |
| 'email' => 'taylor@example.com', | |
| 'password' => 'password', | |
| 'password_confirmation' => 'password', | |
| ]); | |
| expect(in_array($response->getStatusCode(), [302, 403, 404], true))->toBeTrue(); | |
| expect(User::count())->toBe(1); | |
| expect(User::where('email', 'taylor@example.com')->exists())->toBeFalse(); | |
| }); |
Closes #40\n\nSummary:\n- Re-enables Fortify registration\n- Adds a custom registration page using the shared auth shell and UI components\n- Creates users with first and last name fields\n- Adds Pest coverage for registration page rendering and account creation\n\nValidation:\n- vendor/bin/pint --dirty --format agent\n- php artisan test --compact\n- npm run build