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