Skip to content

feat(auth): add public registration for the first account#41

Merged
ibourgeois merged 2 commits into
mainfrom
codex/40-public-registration
Apr 10, 2026
Merged

feat(auth): add public registration for the first account#41
ibourgeois merged 2 commits into
mainfrom
codex/40-public-registration

Conversation

@ibourgeois
Copy link
Copy Markdown
Contributor

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

Copilot AI review requested due to automatic review settings April 10, 2026 10:35
@ibourgeois ibourgeois merged commit 8c26ea3 into main Apr 10, 2026
3 checks passed
@ibourgeois ibourgeois deleted the codex/40-public-registration branch April 10, 2026 10:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread config/fortify.php
Comment on lines 146 to 149
'features' => [
Features::registration(),
Features::resetPasswords(),
// Features::emailVerification(),
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()).

Copilot uses AI. Check for mistakes.
Comment on lines +29 to 33
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'));
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +36
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();
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread routes/web.php
Comment on lines +20 to 21
Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy');
});
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Route::delete('/impersonation', [ImpersonationController::class, 'destroy'])->name('impersonation.destroy');
});
});
Route::delete('/admin/impersonation', [ImpersonationController::class, 'destroy'])
->middleware(['auth'])
->name('admin.impersonation.destroy');

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +36
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');
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +85
@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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +25
$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);
});
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

expect(User::where('email', 'taylor@example.com')->exists())->toBeTrue();
$this->assertAuthenticated();
});
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
});
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();
});

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(auth): add public registration for the first account

2 participants