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