diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index e4c8eb7..16325fb 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -43,10 +43,17 @@ class MagicLinkConsumeController extends Controller Auth::guard('web')->login($magicLink->user); $request->session()->regenerate(); - $home = $magicLink->user->canAccessAdmin() - ? route('dashboard', absolute: false) - : route('me.dashboard', absolute: false); + // Rollensicherer Redirect ohne intended(): eine als Gast besuchte + // Admin-URL (z. B. /dashboard) darf einen Customer nach dem + // Magic-Link-Login nicht in den 403-Admin-Bereich schicken. + $user = $magicLink->user; - return redirect()->intended($home); + $home = $user->canAccessAdmin() + ? route('dashboard', absolute: false) + : ($user->canAccessCustomer() + ? route('me.dashboard', absolute: false) + : '/'); + + return redirect($home); } } diff --git a/app/Listeners/ActivateUserAfterVerification.php b/app/Listeners/ActivateUserAfterVerification.php new file mode 100644 index 0000000..9275131 --- /dev/null +++ b/app/Listeners/ActivateUserAfterVerification.php @@ -0,0 +1,39 @@ +user; + + if (! $user instanceof User) { + return; + } + + if (! $user->is_active) { + $user->forceFill(['is_active' => true])->save(); + } + + // Bestehende Rollen (z. B. Admin, der seine Mail erneut bestätigt) + // bleiben unangetastet; nur rollenlose Selbst-Registrierer werden + // zum customer. + if ($user->roles()->doesntExist()) { + $this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7784a95..ed650d1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,11 +2,11 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\Portal; use App\Enums\RegistrationType; use App\Enums\UserPaymentOptionStatus; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -20,7 +20,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory */ use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51de0bb..4511528 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient; use App\Helpers\ThemeHelper; use App\Http\Middleware\EnsureUserIsAdmin; use App\Http\Middleware\LogSlowAdminRequests; +use App\Listeners\ActivateUserAfterVerification; use App\Models\AdminPreset; use App\Models\Category; use App\Models\CategoryTranslation; @@ -18,8 +19,12 @@ use App\Observers\AdminPerformanceCacheObserver; use App\Services\Admin\AdminRequestPerformanceMetrics; use App\Services\Newsletter\NullNewsletterSyncClient; use App\Services\PressRelease\PressReleaseService; +use Illuminate\Auth\Events\Registered; +use Illuminate\Auth\Events\Verified; +use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; @@ -52,6 +57,10 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + // Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung. + Event::listen(Registered::class, SendEmailVerificationNotification::class); + Event::listen(Verified::class, ActivateUserAfterVerification::class); + // Stripe Tax berechnet die USt im Checkout automatisch nach den // gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer, // EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich diff --git a/app/Services/Import/UserImporter.php b/app/Services/Import/UserImporter.php index 5d7b26b..7619dd8 100644 --- a/app/Services/Import/UserImporter.php +++ b/app/Services/Import/UserImporter.php @@ -13,10 +13,19 @@ use Illuminate\Support\Facades\DB; class UserImporter { - /** Legacy-Gruppen → Spatie-Rollen */ + /** + * Legacy-Gruppen → Spatie-Rollen. + * + * Achtung: Die Alt-Gruppe 2 hieß dort „editor", meinte aber die normale + * Self-Publisher-Masse (Kunden, die ihre eigenen PMs einstellen). Im neuen + * Rollenmodell ist `editor` ein Redaktions-/Staff-Recht MIT Admin-Panel- + * Zugriff (canAccessAdmin). Legacy-Publisher gehören daher auf `customer` + * (nur „Mein Bereich"), nicht auf `editor`. `editor` wird ausschließlich + * manuell an echtes Personal vergeben. + */ private const GROUP_ROLE_MAP = [ 1 => 'admin', - 2 => 'editor', + 2 => 'customer', 3 => 'api-only', 4 => 'customer', ]; diff --git a/bootstrap/app.php b/bootstrap/app.php index 2790638..f0d9356 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,23 @@ return Application::configure(basePath: dirname(__DIR__)) // Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme')) $middleware->append(SetCurrentPortal::class); + // Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt + // werden: rollen- und verifizierungsbewusst statt fix auf /dashboard, + // sonst landet ein Customer dort im 403 und sitzt fest. + $middleware->redirectUsersTo(function (Request $request) { + $user = $request->user(); + + if ($user && ! $user->hasVerifiedEmail()) { + return route('verification.notice'); + } + + if ($user?->canAccessAdmin()) { + return route('dashboard'); + } + + return $user?->canAccessCustomer() ? route('me.dashboard') : '/'; + }); + $middleware->api(prepend: [ LogApiUsage::class, RejectLegacyApiKeys::class, diff --git a/database/migrations/2026_06_15_101337_backfill_email_verified_at_for_existing_users.php b/database/migrations/2026_06_15_101337_backfill_email_verified_at_for_existing_users.php new file mode 100644 index 0000000..1093db3 --- /dev/null +++ b/database/migrations/2026_06_15_101337_backfill_email_verified_at_for_existing_users.php @@ -0,0 +1,34 @@ +whereNull('email_verified_at') + ->update([ + 'email_verified_at' => DB::raw('COALESCE(created_at, CURRENT_TIMESTAMP)'), + ]); + } + + /** + * Intentionally irreversible: we cannot tell which timestamps were + * backfilled versus genuinely set, and re-nulling them would lock users out. + */ + public function down(): void + { + // no-op + } +}; diff --git a/database/migrations/2026_06_16_080913_downgrade_legacy_editor_users_to_customer.php b/database/migrations/2026_06_16_080913_downgrade_legacy_editor_users_to_customer.php new file mode 100644 index 0000000..6fbe6f2 --- /dev/null +++ b/database/migrations/2026_06_16_080913_downgrade_legacy_editor_users_to_customer.php @@ -0,0 +1,86 @@ +where('name', 'editor')->value('id'); + $customerId = DB::table('roles')->where('name', 'customer')->value('id'); + + if (! $editorId || ! $customerId) { + return; + } + + $morphType = (new User)->getMorphClass(); + + $legacyEditorIds = DB::table('model_has_roles as mhr') + ->join('users', 'users.id', '=', 'mhr.model_id') + ->where('mhr.role_id', $editorId) + ->where('mhr.model_type', $morphType) + ->whereNotNull('users.legacy_portal') + ->pluck('users.id'); + + if ($legacyEditorIds->isEmpty()) { + return; + } + + // In Blöcken arbeiten, damit kein whereIn das Placeholder-Limit sprengt. + $legacyEditorIds->chunk(1000)->each(function ($chunk) use ($editorId, $customerId, $morphType) { + $alreadyCustomer = DB::table('model_has_roles') + ->where('role_id', $customerId) + ->where('model_type', $morphType) + ->whereIn('model_id', $chunk) + ->pluck('model_id') + ->flip(); + + // editor-Zuweisung entfernen … + DB::table('model_has_roles') + ->where('role_id', $editorId) + ->where('model_type', $morphType) + ->whereIn('model_id', $chunk) + ->delete(); + + // … und customer setzen, wo noch nicht vorhanden. + $rows = $chunk + ->reject(fn ($id) => $alreadyCustomer->has($id)) + ->map(fn ($id) => [ + 'role_id' => $customerId, + 'model_type' => $morphType, + 'model_id' => $id, + ]) + ->all(); + + if ($rows !== []) { + DB::table('model_has_roles')->insert($rows); + } + }); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + /** + * Nicht umkehrbar: Welche customer zuvor fälschlich editor waren, lässt sich + * nach der Korrektur nicht mehr von echten customer unterscheiden. + */ + public function down(): void + { + // no-op + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e429bb6..ba4a5f7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -32,6 +32,7 @@ class DatabaseSeeder extends Seeder 'registration_type' => RegistrationType::ExistingLegacy->value, 'language' => 'de', 'is_active' => true, + 'email_verified_at' => now(), ]); $rolePermissionSync->assignRoleAndSyncPermissions($adminUser, 'admin'); @@ -45,6 +46,7 @@ class DatabaseSeeder extends Seeder 'registration_type' => RegistrationType::ExistingLegacy->value, 'language' => 'de', 'is_active' => true, + 'email_verified_at' => now(), ]); $rolePermissionSync->assignRoleAndSyncPermissions($testUser, 'customer'); diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 8b0b1c3..e2b0854 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -24,6 +24,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu public bool $remember = false; + // Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular. + public string $magicEmail = ''; + /** * Handle an incoming authentication request. */ @@ -52,6 +55,14 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu RateLimiter::clear($this->throttleKey()); Session::regenerate(); + // Unverifizierte Selbst-Registrierer (Konto angelegt, Mail noch nicht + // bestätigt) gehören auf die Notice-Seite, nicht in die Panel-Logik. + if ($authenticatedUser && ! $authenticatedUser->hasVerifiedEmail()) { + $this->redirect(route('verification.notice', absolute: false)); + + return; + } + // Rollen-basierter Default-Redirect: // Admin/Editor → /dashboard, Customer → /admin/me. // Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt @@ -68,9 +79,12 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu public function sendMagicLink(): void { - $this->validateOnly('email'); + $this->validate( + ['magicEmail' => 'required|string|email'], + attributes: ['magicEmail' => __('E-Mail-Adresse')], + ); - $user = User::query()->where('email', $this->email)->first(); + $user = User::query()->where('email', $this->magicEmail)->first(); if ($user && $user->is_active) { $generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip()); @@ -85,6 +99,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu ); } + $this->reset('magicEmail'); + $this->dispatch('magic-link-sent'); + session()->flash('status', __('If an active account exists for this email, we sent a magic login link.')); } @@ -118,7 +135,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu } }; ?> -
+
@if (session('status'))
{{ session('status') }} @@ -195,17 +212,65 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu + + {{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}} +
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index b746b7a..a7914fb 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -36,21 +36,20 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell unset($validated['terms_accepted']); $validated['password'] = Hash::make($validated['password']); + // Konto bleibt bis zur E-Mail-Verifizierung inaktiv und rollenlos + // (Entscheidung 15.06.). Rolle + Aktivierung erfolgen erst nach dem + // bestätigten Verifizierungslink (ActivateUserAfterVerification). + $validated['is_active'] = false; + // Das Registered-Event versendet über den verdrahteten + // SendEmailVerificationNotification-Listener die Bestätigungsmail. event(new Registered($user = User::create($validated))); Auth::login($user); - // Frisch registrierte User sind in der Regel Customer ohne Admin- - // Rollen → /admin/me. Ohne navigate:true, weil das Panel ein - // anderes Vite-Bundle nutzt als das Hub-Auth-Layout. - $defaultRoute = $user->canAccessAdmin() - ? route('dashboard', absolute: false) - : ($user->canAccessCustomer() - ? route('me.dashboard', absolute: false) - : '/'); - - $this->redirectIntended($defaultRoute); + // Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das + // Panel ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout. + $this->redirect(route('verification.notice', absolute: false)); } }; ?> @@ -171,5 +170,15 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell Konto erstellen Konto wird angelegt … + +
+ + Bereits Konto? + +
+ + + Stattdessen anmelden +
diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php index 219998b..dec7ba0 100644 --- a/resources/views/livewire/auth/verify-email.blade.php +++ b/resources/views/livewire/auth/verify-email.blade.php @@ -1,12 +1,11 @@ 'E-Mail-Adresse bestätigen', 'eyebrow' => 'Konto-Verifizierung', 'showFromBanner' => false])] class extends Component { +new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Danke für Ihre Registrierung', 'eyebrow' => 'Nur noch ein Schritt', 'showFromBanner' => false])] class extends Component { /** * Send an email verification notification to the user. */ @@ -22,23 +21,14 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adress Session::flash('status', 'verification-link-sent'); } - - /** - * Log the current user out of the application. - */ - public function logout(Logout $logout): void - { - $logout(); - - $this->redirect('/', navigate: true); - } }; ?>

- Wir haben Ihnen einen Bestätigungslink an + Ihr Konto wurde angelegt. Wir haben Ihnen einen Bestätigungslink an {{ Auth::user()?->email }} - gesendet. Bitte öffnen Sie die Mail und klicken Sie auf den Link, um Ihre E-Mail-Adresse zu bestätigen. + gesendet. Bitte öffnen Sie die Mail und klicken Sie auf den Link – danach + wird Ihr Konto freigeschaltet und Sie landen direkt in Ihrem Dashboard.

@if (session('status') === 'verification-link-sent') @@ -59,12 +49,11 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adress Mail wird gesendet … - +
+ @csrf + +
diff --git a/routes/auth.php b/routes/auth.php index c24d691..8f563c0 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,6 +1,7 @@ config('fortify.middleware', ['web'])], function ( ->middleware(['guest:'.config('fortify.guard')]) ->name('password.reset'); - // E-Mail-Verifizierung mit Livewire + // E-Mail-Verifizierung: Notice-/Danke-Seite (Volt) Volt::route('/verify-email', 'auth.verify-email') ->middleware(['auth:'.config('fortify.guard')]) ->name('verification.notice'); + // Signierter Bestätigungslink aus der Verifizierungsmail + Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['auth:'.config('fortify.guard'), 'signed', 'throttle:6,1']) + ->name('verification.verify'); + + // Bestätigungsmail erneut anfordern + Route::post('/email/verification-notification', function () { + request()->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + })->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1']) + ->name('verification.send'); + // Passwort bestätigen mit Livewire Volt::route('/confirm-password', 'auth.confirm-password') ->middleware(['auth:'.config('fortify.guard')]) diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index ce5bdd1..d8880e9 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -1,6 +1,7 @@ last_login_ip)->toBe('127.0.0.1'); }); +test('unverified users are redirected to the verification notice on login', function () { + /** @var TestCase $this */ + $user = User::factory()->unverified()->create(['is_active' => false]); + + LivewireVolt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect(route('verification.notice', absolute: false)); + + $this->assertAuthenticated(); +}); + test('users can not authenticate with invalid password', function () { /** @var TestCase $this */ $user = User::factory()->create(); @@ -48,6 +63,30 @@ test('users can not authenticate with invalid password', function () { $this->assertGuest(); }); +test('an authenticated customer visiting guest routes is not trapped on the admin dashboard', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $this->actingAs($customer)->get('/login')->assertRedirect(route('me.dashboard')); + $this->actingAs($customer)->get('/register')->assertRedirect(route('me.dashboard')); +}); + +test('an authenticated admin visiting guest routes lands on the admin dashboard', function () { + /** @var TestCase $this */ + $admin = User::factory()->superAdmin()->create(); + + $this->actingAs($admin)->get('/login')->assertRedirect(route('dashboard')); +}); + +test('an unverified authenticated user visiting guest routes lands on the notice', function () { + /** @var TestCase $this */ + $user = User::factory()->unverified()->create(['is_active' => false]); + + $this->actingAs($user)->get('/login')->assertRedirect(route('verification.notice')); +}); + test('users can logout', function () { /** @var TestCase $this */ $user = User::factory()->create(); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index d806998..5aa67af 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -1,35 +1,39 @@ markTestSkipped('Fortify emailVerification feature is disabled.'); - } +test('the verification notice can be rendered for an unverified user', function () { + /** @var TestCase $this */ + $user = User::factory()->unverified()->create(['is_active' => false]); + + $this->actingAs($user) + ->get('/verify-email') + ->assertStatus(200) + // Abmelden läuft über ein echtes POST-Formular auf die Logout-Route, + // nicht über eine Livewire-Action (verhindert den 419 beim Session-Invalidate). + ->assertSee('action="'.route('logout').'"', false) + ->assertSee('method="POST"', false); }); -test('email verification screen can be rendered', function () { - $user = User::factory()->unverified()->create(); +test('an unverified user is redirected away from the panel to the notice', function () { + /** @var TestCase $this */ + $user = User::factory()->unverified()->create(['is_active' => false]); - $response = $this->actingAs($user)->get('/verify-email'); - - $response->assertStatus(200); + $this->actingAs($user) + ->get(route('me.dashboard')) + ->assertRedirect(route('verification.notice')); }); -test('email can be verified', function () { - $user = User::factory()->unverified()->create(); +test('confirming the signed link verifies, activates and grants the customer role', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); - Event::fake(); + $user = User::factory()->unverified()->create(['is_active' => false]); $verificationUrl = URL::temporarySignedRoute( 'verification.verify', @@ -37,16 +41,37 @@ test('email can be verified', function () { ['id' => $user->id, 'hash' => sha1($user->email)] ); - $response = $this->actingAs($user)->get($verificationUrl); + $this->actingAs($user) + ->get($verificationUrl) + ->assertRedirect(route('me.dashboard', absolute: false).'?verified=1'); - Event::assertDispatched(Verified::class); + $user->refresh(); - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + expect($user->hasVerifiedEmail())->toBeTrue(); + expect($user->is_active)->toBeTrue(); + expect($user->hasRole('customer'))->toBeTrue(); }); -test('email is not verified with invalid hash', function () { - $user = User::factory()->unverified()->create(); +test('verification dispatches the Verified event', function () { + /** @var TestCase $this */ + Event::fake(); + + $user = User::factory()->unverified()->create(['is_active' => false]); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); +}); + +test('the email is not verified with an invalid hash', function () { + /** @var TestCase $this */ + $user = User::factory()->unverified()->create(['is_active' => false]); $verificationUrl = URL::temporarySignedRoute( 'verification.verify', @@ -54,7 +79,7 @@ test('email is not verified with invalid hash', function () { ['id' => $user->id, 'hash' => sha1('wrong-email')] ); - $this->actingAs($user)->get($verificationUrl); + $this->actingAs($user)->get($verificationUrl)->assertForbidden(); expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); }); diff --git a/tests/Feature/Auth/MagicLinkLoginTest.php b/tests/Feature/Auth/MagicLinkLoginTest.php index 2bd2c4b..1542168 100644 --- a/tests/Feature/Auth/MagicLinkLoginTest.php +++ b/tests/Feature/Auth/MagicLinkLoginTest.php @@ -13,7 +13,7 @@ test('user can request a magic login link from login page', function () { $user = User::factory()->create(['is_active' => true]); LivewireVolt::test('auth.login') - ->set('email', $user->email) + ->set('magicEmail', $user->email) ->call('sendMagicLink') ->assertHasNoErrors(); @@ -27,6 +27,27 @@ test('user can request a magic login link from login page', function () { expect($magicLink->token_hash)->toHaveLength(64); }); +test('the magic link modal validates its own email field and closes on success', function () { + Mail::fake(); + $user = User::factory()->create(['is_active' => true]); + + // Leere Eingabe → Validierungsfehler, keine Mail. + LivewireVolt::test('auth.login') + ->set('magicEmail', '') + ->call('sendMagicLink') + ->assertHasErrors(['magicEmail']); + + Mail::assertNothingSent(); + + // Gültige Eingabe → Feld wird geleert, Schließen-Event wird ausgelöst. + LivewireVolt::test('auth.login') + ->set('magicEmail', $user->email) + ->call('sendMagicLink') + ->assertHasNoErrors() + ->assertSet('magicEmail', '') + ->assertDispatched('magic-link-sent'); +}); + test('admin can login with a valid magic link and lands on admin dashboard', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); @@ -35,7 +56,7 @@ test('admin can login with a valid magic link and lands on admin dashboard', fun $user->assignRole('admin'); LivewireVolt::test('auth.login') - ->set('email', $user->email) + ->set('magicEmail', $user->email) ->call('sendMagicLink'); $sentMail = null; @@ -67,7 +88,7 @@ test('customer is redirected to me dashboard after magic link login', function ( $customer->assignRole('customer'); LivewireVolt::test('auth.login') - ->set('email', $customer->email) + ->set('magicEmail', $customer->email) ->call('sendMagicLink'); $sentMail = null; @@ -84,6 +105,29 @@ test('customer is redirected to me dashboard after magic link login', function ( $this->assertAuthenticatedAs($customer); }); +test('a stale intended admin url does not send a customer into the 403 admin area', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $plainToken = 'intended-token'; + MagicLink::query()->create([ + 'user_id' => $customer->id, + 'token_hash' => hash('sha256', $plainToken), + 'purpose' => 'login', + 'expires_at' => now()->addMinutes(5), + ]); + + // Als Gast zuvor /dashboard besucht → intended-URL liegt in der Session. + $this->withSession(['url.intended' => route('dashboard')]); + + $this->get(route('magic-links.consume', ['token' => $plainToken])) + ->assertRedirect(route('me.dashboard', absolute: false)); + + $this->assertAuthenticatedAs($customer); +}); + test('expired magic link can not be used', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index adfd1f9..be11e18 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,28 +1,54 @@ get('/register'); $response->assertStatus(200); }); -test('new users can register', function () { - // terms_accepted ist seit dem Hub-Auth-Refresh Pflicht (AGB-Checkbox). - // Frisch registrierte User haben keine Rolle → Login-Code fällt auf '/' - // zurück (siehe Phase 1 rollen-basierter Redirect in login/register). - $response = Volt::test('auth.register') +test('new users register inactive and are sent to the verification notice', function () { + /** @var TestCase $this */ + Notification::fake(); + + Volt::test('auth.register') ->set('name', 'Test User') ->set('email', 'test@example.com') ->set('password', 'password') ->set('password_confirmation', 'password') ->set('terms_accepted', true) - ->call('register'); - - $response + ->call('register') ->assertHasNoErrors() - ->assertRedirect('/'); + ->assertRedirect(route('verification.notice', absolute: false)); $this->assertAuthenticated(); + + $user = User::where('email', 'test@example.com')->firstOrFail(); + + // Konto bleibt bis zur Verifizierung inaktiv, rollenlos und unverifiziert. + expect($user->is_active)->toBeFalse(); + expect($user->hasVerifiedEmail())->toBeFalse(); + expect($user->roles()->count())->toBe(0); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + +test('registration requires accepting the terms', function () { + /** @var TestCase $this */ + Volt::test('auth.register') + ->set('name', 'Test User') + ->set('email', 'terms@example.com') + ->set('password', 'password') + ->set('password_confirmation', 'password') + ->set('terms_accepted', false) + ->call('register') + ->assertHasErrors(['terms_accepted']); + + expect(User::where('email', 'terms@example.com')->exists())->toBeFalse(); }); diff --git a/tests/Feature/LegacyUserProfileImportTest.php b/tests/Feature/LegacyUserProfileImportTest.php index 341531b..a1049e2 100644 --- a/tests/Feature/LegacyUserProfileImportTest.php +++ b/tests/Feature/LegacyUserProfileImportTest.php @@ -128,3 +128,111 @@ test('legacy user import creates linked profile data from sf guard user profile' expect($user->profile?->tax_exempt_reason)->toBe('Reverse Charge'); expect($user->profile?->disable_footer_code)->toBeTrue(); }); + +/** + * Baut das minimale Legacy-Schema und importiert einen User in der gegebenen + * Alt-Gruppe. Dient der Rollen-Mapping-Absicherung. + */ +function importLegacyUserInGroup(int $groupId, string $email): void +{ + Config::set('database.connections.mysql_presseecho', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => false, + ]); + + DB::purge('mysql_presseecho'); + + Schema::connection('mysql_presseecho')->create('sf_guard_user', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('username')->nullable(); + $table->boolean('is_active')->default(true); + $table->boolean('is_super_admin')->default(false); + $table->timestamp('last_login')->nullable(); + $table->string('ip_address')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::connection('mysql_presseecho')->create('sf_guard_user_profile', function (Blueprint $table): void { + $table->integer('user_id')->primary(); + $table->string('email'); + $table->integer('salutation_id')->nullable(); + $table->string('title')->nullable(); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); + $table->string('address', 1000)->nullable(); + $table->integer('country_id')->nullable(); + $table->string('phone')->nullable(); + $table->date('birthdate')->nullable(); + $table->string('language')->nullable(); + $table->string('backlink_url')->nullable(); + $table->boolean('show_stats')->default(false); + $table->timestamp('validation_date')->nullable(); + $table->timestamp('contract_date')->nullable(); + $table->string('registration_type')->nullable(); + $table->string('validate')->nullable(); + $table->string('tax_id_number')->nullable(); + $table->boolean('tax_exempt')->default(false); + $table->string('tax_exempt_reason', 1000)->nullable(); + $table->boolean('disable_footer_code')->default(false); + }); + + Schema::connection('mysql_presseecho')->create('sf_guard_user_group', function (Blueprint $table): void { + $table->integer('user_id'); + $table->integer('group_id'); + }); + + DB::connection('mysql_presseecho')->table('sf_guard_user')->insert([ + 'id' => 77, + 'username' => 'grp-user', + 'is_active' => true, + 'is_super_admin' => false, + 'created_at' => '2020-01-01 00:00:00', + 'updated_at' => '2020-01-02 00:00:00', + ]); + + DB::connection('mysql_presseecho')->table('sf_guard_user_profile')->insert([ + 'user_id' => 77, + 'email' => $email, + 'first_name' => 'Gruppen', + 'last_name' => 'Nutzer', + 'language' => 'de', + ]); + + DB::connection('mysql_presseecho')->table('sf_guard_user_group')->insert([ + 'user_id' => 77, + 'group_id' => $groupId, + ]); + + app(UserImporter::class)->run(new ImportContext('presseecho', false, true)); +} + +test('legacy group 2 (self-publisher) imports as customer without admin access', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + importLegacyUserInGroup(2, 'legacy-group2@example.com'); + + $user = User::query()->where('email', 'legacy-group2@example.com')->firstOrFail(); + + // Alt-Gruppe 2 ("editor") darf NICHT die neue editor-Rolle (Admin-Zugriff) + // erhalten, sondern customer ("Mein Bereich"). + expect($user->hasRole('customer'))->toBeTrue(); + expect($user->hasRole('editor'))->toBeFalse(); + expect($user->canAccessAdmin())->toBeFalse(); + expect($user->canAccessCustomer())->toBeTrue(); +}); + +test('legacy group 1 still imports as admin', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + importLegacyUserInGroup(1, 'legacy-group1@example.com'); + + $user = User::query()->where('email', 'legacy-group1@example.com')->firstOrFail(); + + expect($user->hasRole('admin'))->toBeTrue(); + expect($user->canAccessAdmin())->toBeTrue(); +});