From 544b3c4699c801ea21cfe1ee18e5b8df6965a2db Mon Sep 17 00:00:00 2001 From: roble Date: Sun, 30 Aug 2026 20:16:14 +0100 Subject: [PATCH] feat(auth): add registration toggle and handle registration logic in socialite flow --- CLAUDE.md | 5 ++-- ..._registration_enabled_to_auth_settings.php | 11 +++++++ lang/en/auth.php | 6 ++++ lang/en/socialite.php | 1 + lang/pt_BR/auth.php | 6 ++++ lang/pt_BR/socialite.php | 1 + resources/js/react/pages/Login.tsx | 30 ++++++++++++------- resources/js/types/page-props.d.ts | 1 + resources/js/vue/pages/Login.vue | 1 + routes/web.php | 9 ++++-- src/Exceptions/SocialiteException.php | 5 ++++ src/Filament/Pages/AuthenticationSettings.php | 10 +++++++ src/Http/Controllers/SocialiteController.php | 8 ++++- .../Middleware/EnsureRegistrationEnabled.php | 19 ++++++++++++ src/Providers/AuthServiceProvider.php | 4 +++ src/Services/SocialiteService.php | 4 +++ src/Settings/AuthSettings.php | 2 ++ tests/Feature/AuthSettingsTest.php | 1 + tests/Feature/RegisterTest.php | 29 ++++++++++++++++++ tests/Feature/SocialiteCallbackTest.php | 29 ++++++++++++++++++ 20 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 database/settings/2026_08_30_120000_add_registration_enabled_to_auth_settings.php create mode 100644 src/Http/Middleware/EnsureRegistrationEnabled.php diff --git a/CLAUDE.md b/CLAUDE.md index 1c4b260..f106159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ Authentication, registration, magic link (passwordless), password reset, email v | Models | `SocialAccount` (provider, tokens, avatar, last_login_at), `MagicLinkToken` (hashed token, expires_at, used_at) | | Service | `SocialiteService` — all OAuth logic (find/create user, link/disconnect accounts) | | Requests | `LoginRequest` (credential validation + rate limiting), `RegisterRequest` (password hashing in `passedValidation`) | -| Exceptions | `AuthException` (credentials, throttle), `SocialiteException` (disconnect, account linking, provider validation) | +| Exceptions | `AuthException` (credentials, throttle), `SocialiteException` (disconnect, account linking, provider validation, registration disabled) | | Listeners | `AssignUserRole` (Registered), `UpdateUserLastLogin` (Login), `Impersonation` (TakeImpersonation — session history) | | Notifications | `WelcomeNotification` (Registered), `MagicLinkNotification` (passwordless login link with configured expiry) | -| Settings | `AuthSettings` (`magic_link_enabled`, `magic_link_expiry`) | +| Settings | `AuthSettings` (`registration_enabled`, `magic_link_enabled`, `magic_link_expiry`, `login_notification_enabled`, `enabled_socialite_providers`) | | Trait | `Sociable` — added to User model (socialAccounts relation, connected_providers, disconnect) | | Filament | `AuthPlugin`, `AuthenticationSettings`, `UserResource` (list, create, view, edit), `UserForm`, `UsersTable` | | Pages | `Login`, `Register`, `ForgotPassword`, `ResetPassword`, `VerifyEmail`, `MagicLink` | @@ -102,3 +102,4 @@ npx playwright test --project="@auth*" # E2E - Filament UserResource enforces single role (maxItems: 1) despite multi-select UI - Magic link authenticate route is outside both guest and auth middleware groups (link is clicked from email client) - `MagicLinkToken::isValid()` checks both `expires_at->isFuture()` and `used_at === null` +- `registration_enabled` closes both signup paths: `EnsureRegistrationEnabled` 404s the register routes, and `SocialiteService::handleCallback()` throws `registrationDisabled()` rather than creating a new user (existing users still sign in). Login is deliberately not toggleable — disabling it would lock out admins. diff --git a/database/settings/2026_08_30_120000_add_registration_enabled_to_auth_settings.php b/database/settings/2026_08_30_120000_add_registration_enabled_to_auth_settings.php new file mode 100644 index 0000000..99cb5f8 --- /dev/null +++ b/database/settings/2026_08_30_120000_add_registration_enabled_to_auth_settings.php @@ -0,0 +1,11 @@ +migrator->add('auth.registration_enabled', true); + } +}; diff --git a/lang/en/auth.php b/lang/en/auth.php index 96db0d0..b08d564 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -20,6 +20,12 @@ 'verification-link-sent' => 'A fresh verification link has been sent to your email address.', 'magic-link-sent' => "If an account with that email exists, we've sent a magic login link.", 'magic-link-expired' => 'This magic link has expired or has already been used.', + 'registration' => [ + 'title' => 'Registration', + 'description' => 'Control whether visitors can create new accounts.', + 'enabled' => 'Allow new registrations', + 'help' => 'When disabled, the sign-up page returns 404 and social login cannot create new accounts.', + ], 'notifications' => [ 'title' => 'Notifications', 'description' => 'Configure security notifications sent to users.', diff --git a/lang/en/socialite.php b/lang/en/socialite.php index 0898bab..b089625 100644 --- a/lang/en/socialite.php +++ b/lang/en/socialite.php @@ -25,4 +25,5 @@ 'missing_social_accounts_relation' => 'The User model is missing the socialAccounts relationship required for social authentication', 'account_already_linked' => 'This :provider account is already linked to another user', 'unsupported_provider' => 'The social provider :provider is not supported', + 'registration_disabled' => 'New account registration is currently disabled', ]; diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php index 70f2231..15ab973 100644 --- a/lang/pt_BR/auth.php +++ b/lang/pt_BR/auth.php @@ -18,6 +18,12 @@ 'password' => 'A senha fornecida está incorreta.', 'throttle' => 'Muitas tentativas de login. Tente novamente em :seconds segundos.', 'verification-link-sent' => 'Um novo link de verificação foi enviado para seu endereço de email.', + 'registration' => [ + 'title' => 'Cadastro', + 'description' => 'Controle se visitantes podem criar novas contas.', + 'enabled' => 'Permitir novos cadastros', + 'help' => 'Quando desativado, a página de cadastro retorna 404 e o login social não cria novas contas.', + ], 'notifications' => [ 'title' => 'Notificações', 'description' => 'Configure as notificações de segurança enviadas aos usuários.', diff --git a/lang/pt_BR/socialite.php b/lang/pt_BR/socialite.php index c41b0ff..a8b0d64 100644 --- a/lang/pt_BR/socialite.php +++ b/lang/pt_BR/socialite.php @@ -26,4 +26,5 @@ 'missing_social_accounts_relation' => 'O modelo User está faltando o relacionamento socialAccounts necessário para autenticação social', 'account_already_linked' => 'Esta conta :provider já está vinculada a outro usuário', 'unsupported_provider' => 'O provedor social :provider não é suportado', + 'registration_disabled' => 'O cadastro de novas contas está desativado no momento', ]; diff --git a/resources/js/react/pages/Login.tsx b/resources/js/react/pages/Login.tsx index fc63613..759b198 100644 --- a/resources/js/react/pages/Login.tsx +++ b/resources/js/react/pages/Login.tsx @@ -10,9 +10,15 @@ import { useState } from 'react'; import SocialiteProviders from '../components/SocialiteProviders'; import AuthCardLayout from '../layouts/AuthCardLayout'; +type AuthProps = { + magic_link_enabled?: boolean; + registration_enabled?: boolean; +}; + export default function Login() { const t = useT(); const page = usePage(); + const auth = (page.props.auth as AuthProps) ?? {}; const [email, setEmail] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -142,7 +148,7 @@ export default function Login() {

- {(page.props.auth as any)?.magic_link_enabled && ( + {auth.magic_link_enabled && ( -

- {t("Don't have an account?")}{' '} - - {t('Sign up')} - -

+ {auth.registration_enabled && ( +

+ {t("Don't have an account?")}{' '} + + {t('Sign up')} + +

+ )} ); diff --git a/resources/js/types/page-props.d.ts b/resources/js/types/page-props.d.ts index 82de02e..d7536fc 100644 --- a/resources/js/types/page-props.d.ts +++ b/resources/js/types/page-props.d.ts @@ -6,6 +6,7 @@ declare module '@inertiajs/core' { user: User | null; last_social_provider?: string | null; magic_link_enabled?: boolean; + registration_enabled?: boolean; socialite_providers?: Array<{ name: string; label: string; diff --git a/resources/js/vue/pages/Login.vue b/resources/js/vue/pages/Login.vue index 77ac8d1..5cb43ee 100644 --- a/resources/js/vue/pages/Login.vue +++ b/resources/js/vue/pages/Login.vue @@ -101,6 +101,7 @@ const forgotUrl = computed(() =>

{{ $t("Don't have an account?") }} diff --git a/routes/web.php b/routes/web.php index 6828f2c..0966973 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use Modules\Auth\Http\Controllers\ResetPasswordController; use Modules\Auth\Http\Controllers\SocialiteController; use Modules\Auth\Http\Controllers\VerifyEmailController; +use Modules\Auth\Http\Middleware\EnsureRegistrationEnabled; use Modules\Auth\Http\Middleware\EnsureSocialiteProviderEnabled; Route::middleware('web')->group(function (): void { @@ -23,10 +24,12 @@ Route::post('login', [LoginController::class, 'store']); - Route::get('register', [RegisterController::class, 'create']) - ->name('register'); + Route::middleware(EnsureRegistrationEnabled::class)->group(function (): void { + Route::get('register', [RegisterController::class, 'create']) + ->name('register'); - Route::post('register', [RegisterController::class, 'store']); + Route::post('register', [RegisterController::class, 'store']); + }); Route::get('forgot-password', [ForgotPasswordController::class, 'create']) ->name('password.request'); diff --git a/src/Exceptions/SocialiteException.php b/src/Exceptions/SocialiteException.php index 097c83a..9fdb1de 100644 --- a/src/Exceptions/SocialiteException.php +++ b/src/Exceptions/SocialiteException.php @@ -36,6 +36,11 @@ public static function accountAlreadyLinked(string $provider): self return new self(trans('auth::socialite.account_already_linked', ['provider' => ucfirst($provider)])); } + public static function registrationDisabled(): self + { + return new self(trans('auth::socialite.registration_disabled')); + } + public static function unsupportedProvider(string $provider): self { return new self(trans('auth::socialite.unsupported_provider', ['provider' => ucfirst($provider)])); diff --git a/src/Filament/Pages/AuthenticationSettings.php b/src/Filament/Pages/AuthenticationSettings.php index a9ae601..85d1824 100644 --- a/src/Filament/Pages/AuthenticationSettings.php +++ b/src/Filament/Pages/AuthenticationSettings.php @@ -35,6 +35,16 @@ public function getTitle(): string public function form(Schema $schema): Schema { return $schema->columns(1)->components([ + Section::make(__('auth::auth.registration.title')) + ->description(__('auth::auth.registration.description')) + ->icon(Heroicon::OutlinedUserPlus) + ->schema([ + Toggle::make('registration_enabled') + ->label(__('auth::auth.registration.enabled')) + ->helperText(__('auth::auth.registration.help')) + ->extraAttributes(['data-testid' => 'admin-registration-enabled']), + ]) + ->columns(1), Section::make(__('Social Login')) ->description(__('Choose which social providers visitors can use to sign in or create an account.')) ->icon(Heroicon::OutlinedShare) diff --git a/src/Http/Controllers/SocialiteController.php b/src/Http/Controllers/SocialiteController.php index 92aff1e..d7647c9 100644 --- a/src/Http/Controllers/SocialiteController.php +++ b/src/Http/Controllers/SocialiteController.php @@ -55,7 +55,13 @@ public function callback(Request $request, string $provider): RedirectResponse } // Guest user - login/registration flow - $user = $this->socialiteService->handleCallback($provider); + try { + $user = $this->socialiteService->handleCallback($provider); + } catch (SocialiteException $e) { + Toast::error($e->getMessage()); + + return redirect()->route('login'); + } Auth::login($user); diff --git a/src/Http/Middleware/EnsureRegistrationEnabled.php b/src/Http/Middleware/EnsureRegistrationEnabled.php new file mode 100644 index 0000000..a8155d6 --- /dev/null +++ b/src/Http/Middleware/EnsureRegistrationEnabled.php @@ -0,0 +1,19 @@ +settings->registration_enabled, 404); + + return $next($request); + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index 6e6fed2..91b7bdc 100644 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -25,6 +25,10 @@ protected function shareInertiaData(): void 'auth.socialite_providers', fn (): array => $this->app->make(SocialiteService::class)->enabledProviders(), ); + Inertia::share( + 'auth.registration_enabled', + fn (): bool => $this->app->make(AuthSettings::class)->registration_enabled, + ); Inertia::share( 'auth.magic_link_enabled', fn (): bool => $this->app->make(AuthSettings::class)->magic_link_enabled, diff --git a/src/Services/SocialiteService.php b/src/Services/SocialiteService.php index d77cfbf..95acf8c 100644 --- a/src/Services/SocialiteService.php +++ b/src/Services/SocialiteService.php @@ -96,6 +96,10 @@ public function handleCallback(string $provider): User ->first(); if (! $user) { + if (! $this->settings->registration_enabled) { + throw SocialiteException::registrationDisabled(); + } + $user = $this->createNewUser($socialiteUser, $avatarUrl); } else { $this->updateUserAvatar($user, $avatarUrl); diff --git a/src/Settings/AuthSettings.php b/src/Settings/AuthSettings.php index c0bd2c9..105a102 100644 --- a/src/Settings/AuthSettings.php +++ b/src/Settings/AuthSettings.php @@ -9,6 +9,8 @@ class AuthSettings extends Settings /** @var list */ public array $enabled_socialite_providers; + public bool $registration_enabled; + public bool $magic_link_enabled; public int $magic_link_expiry; diff --git a/tests/Feature/AuthSettingsTest.php b/tests/Feature/AuthSettingsTest.php index 08ea9cc..5265230 100644 --- a/tests/Feature/AuthSettingsTest.php +++ b/tests/Feature/AuthSettingsTest.php @@ -15,6 +15,7 @@ public function test_fresh_install_has_authentication_defaults(): void $settings = app(AuthSettings::class); $this->assertSame([], $settings->enabled_socialite_providers); + $this->assertTrue($settings->registration_enabled); $this->assertTrue($settings->magic_link_enabled); $this->assertSame(15, $settings->magic_link_expiry); $this->assertFalse($settings->login_notification_enabled); diff --git a/tests/Feature/RegisterTest.php b/tests/Feature/RegisterTest.php index 078c7a0..f49b10b 100644 --- a/tests/Feature/RegisterTest.php +++ b/tests/Feature/RegisterTest.php @@ -22,6 +22,35 @@ public function test_register_page_renders_for_guests(): void $response->assertStatus(200); } + public function test_register_page_is_not_found_when_registration_is_disabled(): void + { + $this->disableRegistration(); + + $this->get(route('register'))->assertNotFound(); + } + + public function test_user_cannot_register_when_registration_is_disabled(): void + { + $this->disableRegistration(); + + $this->post(route('register'), [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'terms' => true, + ])->assertNotFound(); + + $this->assertDatabaseMissing('users', ['email' => 'test@example.com']); + } + + private function disableRegistration(): void + { + $settings = app(AuthSettings::class); + $settings->registration_enabled = false; + $settings->save(); + } + public function test_user_can_register_with_valid_data(): void { Notification::fake(); diff --git a/tests/Feature/SocialiteCallbackTest.php b/tests/Feature/SocialiteCallbackTest.php index 5cb0fd7..fc4f941 100644 --- a/tests/Feature/SocialiteCallbackTest.php +++ b/tests/Feature/SocialiteCallbackTest.php @@ -55,6 +55,35 @@ private function mockSocialiteDriver(SocialiteUser $socialiteUser): void ); } + public function test_social_callback_does_not_create_user_when_registration_is_disabled(): void + { + $settings = $this->enableGithub(); + $settings->registration_enabled = false; + $settings->save(); + + $this->mockSocialiteDriver($this->makeSocialiteUser()); + + $response = $this->get(route('auth.socialite.callback', ['provider' => 'github'])); + + $response->assertRedirect(route('login')); + $this->assertGuest(); + $this->assertDatabaseMissing('users', ['email' => 'socialuser@example.com']); + } + + public function test_social_callback_still_logs_in_existing_user_when_registration_is_disabled(): void + { + $settings = $this->enableGithub(); + $settings->registration_enabled = false; + $settings->save(); + + $user = $this->createUser(); + $this->mockSocialiteDriver($this->makeSocialiteUser(email: $user->email)); + + $this->get(route('auth.socialite.callback', ['provider' => 'github'])); + + $this->assertAuthenticatedAs($user); + } + public function test_callback_sets_last_social_provider_cookie(): void { $this->enableGithub();