-
-
-
-
-
-
-
-
-
-
- Forgot password?
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+ {{ t('auth.login.forgot') }}
+
+
+
+
{{ t('auth.login.help') }}
+
diff --git a/resources/js/pages/auth/TwoFactorChallenge.vue b/resources/js/pages/auth/TwoFactorChallenge.vue
index 51f945d..15ffc3c 100644
--- a/resources/js/pages/auth/TwoFactorChallenge.vue
+++ b/resources/js/pages/auth/TwoFactorChallenge.vue
@@ -1,133 +1,173 @@
-
-
-
-
+
+
+
+ {{
+ t('auth.backToLogin')
+ }}
+
diff --git a/resources/js/pages/auth/VerifyEmail.vue b/resources/js/pages/auth/VerifyEmail.vue
index cf4991c..193f033 100644
--- a/resources/js/pages/auth/VerifyEmail.vue
+++ b/resources/js/pages/auth/VerifyEmail.vue
@@ -1,49 +1,124 @@
-
-
-
+
- A new verification link has been sent to the email address you
- provided during registration.
+
+ {{ t('auth.verify.emailLabel') }}
+
+
{{ email }}
-
-
+
-
+
+
+
+ {{ t('auth.verify.help') }}
+
+
+
+
+
{{ t('auth.verify.wrongAccount') }}
+
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
index 1ed655b..bd7a0ae 100644
--- a/tests/Feature/Auth/AuthenticationTest.php
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
@@ -85,7 +86,7 @@ class AuthenticationTest extends TestCase
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
- ]);
+ ])->assertSessionHasErrors('email');
$this->assertGuest();
}
@@ -100,6 +101,22 @@ class AuthenticationTest extends TestCase
$response->assertRedirect(route('home'));
}
+ public function test_login_preserves_the_intended_destination_and_remember_choice(): void
+ {
+ $user = User::factory()->create();
+
+ $this->withSession(['url.intended' => route('dashboard')])
+ ->post(route('login.store'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ 'remember' => true,
+ ])
+ ->assertRedirect(route('dashboard'))
+ ->assertCookie(Auth::guard('web')->getRecallerName());
+
+ $this->assertAuthenticatedAs($user);
+ }
+
public function test_users_are_rate_limited()
{
$user = User::factory()->create();
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php
index dba8afa..7ee30bf 100644
--- a/tests/Feature/Auth/EmailVerificationTest.php
+++ b/tests/Feature/Auth/EmailVerificationTest.php
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
- $this->actingAs($user)->get($verificationUrl);
+ $this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
['id' => 123, 'hash' => sha1($user->email)],
);
- $this->actingAs($user)->get($verificationUrl);
+ $this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -97,6 +97,34 @@ class EmailVerificationTest extends TestCase
$response->assertRedirect(route('dashboard', absolute: false));
}
+ public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
+ {
+ $user = User::factory()->unverified()->create();
+
+ $this->actingAs($user)->get(route('dashboard'))->assertOk();
+
+ $this->assertFalse($user->fresh()->hasVerifiedEmail());
+ }
+
+ public function test_guests_cannot_view_the_verification_prompt(): void
+ {
+ $this->get(route('verification.notice'))->assertRedirect(route('login'));
+ }
+
+ public function test_expired_verification_links_cannot_verify_an_email(): void
+ {
+ $user = User::factory()->unverified()->create();
+ $verificationUrl = URL::temporarySignedRoute(
+ 'verification.verify',
+ now()->subMinute(),
+ ['id' => $user->id, 'hash' => sha1($user->email)],
+ );
+
+ $this->actingAs($user)->get($verificationUrl)->assertForbidden();
+
+ $this->assertFalse($user->fresh()->hasVerifiedEmail());
+ }
+
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{
$user = User::factory()->create();
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
index f1c1c80..ce93773 100644
--- a/tests/Feature/Auth/TwoFactorChallengeTest.php
+++ b/tests/Feature/Auth/TwoFactorChallengeTest.php
@@ -4,7 +4,9 @@ namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Auth;
use Inertia\Testing\AssertableInertia as Assert;
+use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -52,4 +54,77 @@ class TwoFactorChallengeTest extends TestCase
->component('auth/TwoFactorChallenge'),
);
}
+
+ public function test_valid_authenticator_code_completes_the_pending_login(): void
+ {
+ $user = User::factory()->withTwoFactor()->create();
+ $this->mock(TwoFactorAuthenticationProvider::class)
+ ->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
+
+ $this->withSession(['url.intended' => route('dashboard')])
+ ->post(route('login.store'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ 'remember' => true,
+ ])->assertRedirect(route('two-factor.login'));
+ $this->assertGuest();
+
+ $this->post(route('two-factor.login.store'), ['code' => '012345'])
+ ->assertRedirect(route('dashboard'))
+ ->assertSessionMissing('login.id')
+ ->assertSessionMissing('login.remember')
+ ->assertCookie(Auth::guard('web')->getRecallerName());
+
+ $this->assertAuthenticatedAs($user);
+ }
+
+ public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
+ {
+ $user = User::factory()->withTwoFactor()->create();
+ $this->mock(TwoFactorAuthenticationProvider::class)
+ ->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
+
+ $this->withSession(['login.id' => $user->id])
+ ->from(route('two-factor.login'))
+ ->post(route('two-factor.login.store'), ['code' => '000000'])
+ ->assertRedirect(route('two-factor.login'))
+ ->assertSessionHasErrors('code')
+ ->assertSessionHas('login.id', $user->id);
+
+ $this->assertGuest();
+ }
+
+ public function test_recovery_code_signs_in_once_and_is_replaced(): void
+ {
+ $user = User::factory()->withTwoFactor()->create();
+
+ $this->withSession(['login.id' => $user->id, 'login.remember' => true])
+ ->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
+ ->assertRedirect(route('dashboard', absolute: false))
+ ->assertSessionMissing('login.id')
+ ->assertCookie(Auth::guard('web')->getRecallerName());
+
+ $this->assertAuthenticatedAs($user);
+ $this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
+ $this->assertCount(1, $user->fresh()->recoveryCodes());
+
+ $this->post(route('logout'));
+ $this->withSession(['login.id' => $user->id])
+ ->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
+ ->assertSessionHasErrors('recovery_code')
+ ->assertSessionHas('login.id', $user->id);
+
+ $this->assertGuest();
+ }
+
+ public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
+ {
+ $this->from(route('two-factor.login'))
+ ->post(route('two-factor.login.store'), ['code' => '012345'])
+ ->assertRedirect(route('two-factor.login'))
+ ->assertSessionHasErrors('code');
+
+ $this->assertGuest();
+ $this->get(route('two-factor.login'))->assertRedirect(route('login'));
+ }
}
diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php
index 389b537..0aec6c8 100644
--- a/tests/Feature/Auth/VerificationNotificationTest.php
+++ b/tests/Feature/Auth/VerificationNotificationTest.php
@@ -28,7 +28,8 @@ class VerificationNotificationTest extends TestCase
$this->actingAs($user)
->post(route('verification.send'))
- ->assertRedirect(route('home'));
+ ->assertRedirect(route('home'))
+ ->assertSessionHas('status', 'verification-link-sent');
Notification::assertSentTo($user, VerifyEmail::class);
}
@@ -45,4 +46,27 @@ class VerificationNotificationTest extends TestCase
Notification::assertNothingSent();
}
+
+ public function test_guests_cannot_request_a_verification_email(): void
+ {
+ Notification::fake();
+
+ $this->post(route('verification.send'))->assertRedirect(route('login'));
+
+ Notification::assertNothingSent();
+ }
+
+ public function test_resending_verification_emails_is_throttled(): void
+ {
+ Notification::fake();
+ $user = User::factory()->unverified()->create();
+ $this->actingAs($user);
+
+ for ($attempt = 0; $attempt < 6; $attempt++) {
+ $this->post(route('verification.send'))->assertRedirect();
+ }
+
+ $this->post(route('verification.send'))->assertTooManyRequests();
+ Notification::assertSentToTimes($user, VerifyEmail::class, 6);
+ }
}
diff --git a/tests/Fixtures/auth-browser.mjs b/tests/Fixtures/auth-browser.mjs
new file mode 100644
index 0000000..2f3cddf
--- /dev/null
+++ b/tests/Fixtures/auth-browser.mjs
@@ -0,0 +1,342 @@
+import assert from 'node:assert/strict';
+import process from 'node:process';
+
+const { chromium } = await import(
+ process.env.PLAYWRIGHT_MODULE ?? 'playwright'
+);
+const browser = await chromium.launch({ channel: 'chrome', headless: true });
+const base = 'http://127.0.0.1:4177';
+const context = await browser.newContext();
+const page = await context.newPage();
+page.setDefaultTimeout(10000);
+const errors = [];
+const submissions = [];
+page.on('pageerror', (error) => errors.push(error.message));
+page.on('request', (request) => {
+ if (request.method() === 'POST') {
+ submissions.push({
+ path: new URL(request.url()).pathname,
+ data: request.postDataJSON(),
+ });
+ }
+});
+const outcome = (value) =>
+ context.addCookies([{ name: 'auth_test_result', value, url: base }]);
+const open = async (path) => {
+ await page.goto(base + path);
+ await page.locator('main h1').waitFor();
+};
+const state = (value) =>
+ page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
+const credentials = async () => {
+ await page
+ .getByLabel('Email address', { exact: true })
+ .fill('alex@example.test');
+ await page
+ .getByLabel('Password', { exact: true })
+ .fill('fixture-private-password');
+};
+const noStoredSecrets = async () => {
+ const persisted = await page.evaluate(() =>
+ JSON.stringify({
+ local: { ...localStorage },
+ session: { ...sessionStorage },
+ history: history.state,
+ }),
+ );
+ for (const secret of [
+ 'fixture-private-password',
+ '012345',
+ 'fixture-recovery-code',
+ ]) {
+ assert.ok(
+ !persisted.includes(secret),
+ 'Credentials must not be remembered in browser storage',
+ );
+ }
+};
+
+try {
+ for (const width of [320, 390, 1440]) {
+ await page.setViewportSize({ width, height: 900 });
+ for (const theme of ['light', 'dark']) {
+ await page.emulateMedia({ colorScheme: theme });
+ for (const path of [
+ '/login',
+ '/email/verify',
+ '/two-factor-challenge',
+ ]) {
+ await open(path);
+ await page
+ .getByRole('button', {
+ name: theme === 'light' ? 'Light' : 'Dark',
+ exact: true,
+ })
+ .click();
+ await page
+ .getByRole('button', {
+ name: theme === 'light' ? 'Light' : 'Dark',
+ exact: true,
+ pressed: true,
+ })
+ .waitFor();
+ assert.equal(
+ await page
+ .locator('html')
+ .evaluate((el) => el.classList.contains('dark')),
+ theme === 'dark',
+ );
+ assert.equal(
+ await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth <= innerWidth,
+ ),
+ true,
+ `${path} must fit at ${width}`,
+ );
+ const controls = await page
+ .locator(
+ 'main button:not([role="checkbox"]), main label[for="remember"], main input:not([type="checkbox"]):not([aria-hidden="true"])',
+ )
+ .evaluateAll((els) =>
+ els
+ .filter(
+ (el) => el.getBoundingClientRect().width > 0,
+ )
+ .map((el) => el.getBoundingClientRect().height),
+ );
+ assert.ok(
+ controls.every((height) => height >= 44),
+ `${path} has touch-sized controls`,
+ );
+ await page.screenshot({
+ path: `/private/tmp/kan21-${path.split('/').at(-1)}-${width}-${theme}.png`,
+ fullPage: true,
+ });
+ }
+ }
+ }
+ await page.setViewportSize({ width: 390, height: 480 });
+ await open('/login');
+ await page.getByRole('button', { name: 'System', exact: true }).click();
+ await page.emulateMedia({ colorScheme: 'light' });
+ await page.waitForFunction(
+ () => !document.documentElement.classList.contains('dark'),
+ );
+ await page.emulateMedia({ colorScheme: 'dark' });
+ await page.waitForFunction(() =>
+ document.documentElement.classList.contains('dark'),
+ );
+ await credentials();
+ const password = page.locator('#password');
+ assert.equal(
+ await password.getAttribute('autocomplete'),
+ 'current-password',
+ );
+ assert.equal(
+ await page.locator('#email').getAttribute('autocomplete'),
+ 'username',
+ );
+ await password.focus();
+ await page.keyboard.press('Tab');
+ assert.equal(
+ await page
+ .getByRole('button', { name: 'Show password' })
+ .evaluate((el) => el === document.activeElement),
+ true,
+ );
+ await page.keyboard.press('Enter');
+ assert.equal(await password.getAttribute('type'), 'text');
+ await page.getByRole('button', { name: 'Hide password' }).click();
+ await page.getByRole('checkbox', { name: 'Remember me' }).check();
+ await outcome('validation');
+ await password.press('Enter');
+ await state('validation');
+ assert.equal(await password.inputValue(), 'fixture-private-password');
+ assert.equal(
+ await page.locator('#email').inputValue(),
+ 'alex@example.test',
+ );
+ assert.equal(
+ await page.locator('#email').getAttribute('aria-invalid'),
+ 'true',
+ );
+ assert.equal(submissions.at(-1).data.remember, true);
+ assert.equal(
+ await page
+ .locator('[data-form-state="validation"]')
+ .evaluate((el) => el === document.activeElement),
+ true,
+ );
+ await noStoredSecrets();
+
+ await page.route('**/login', (route) =>
+ route.request().method() === 'POST'
+ ? route.abort('failed')
+ : route.continue(),
+ );
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
+ await state('network');
+ assert.equal(await password.inputValue(), 'fixture-private-password');
+ await page.unroute('**/login');
+ await outcome('limited');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await page.getByText('Too many attempts', { exact: true }).waitFor();
+ await outcome('expired');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await page.getByText('Your session has expired', { exact: true }).waitFor();
+ assert.equal(
+ await page.getByRole('button', { name: 'Try again' }).count(),
+ 0,
+ );
+ await page.getByRole('link', { name: 'Return to sign in' }).click();
+ await page.waitForFunction(
+ () => document.querySelector('#password')?.value === '',
+ );
+
+ await outcome('success');
+ await credentials();
+ let release;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.route('**/login', async (route) => {
+ await held;
+ await route.continue();
+ });
+ const before = submissions.length;
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
+ await state('loading');
+ assert.equal(await password.isDisabled(), true);
+ assert.equal(
+ await page.getByRole('button', { name: 'Show password' }).isDisabled(),
+ true,
+ );
+ await page
+ .locator('form')
+ .evaluate((el) =>
+ el.dispatchEvent(
+ new Event('submit', { bubbles: true, cancelable: true }),
+ ),
+ );
+ assert.equal(submissions.length, before + 1);
+ release();
+ await page
+ .getByRole('heading', { name: 'Signed in destination' })
+ .waitFor();
+ await page.unroute('**/login');
+ await noStoredSecrets();
+ await page.goBack();
+ await page.locator('#password').waitFor();
+ assert.equal(await password.inputValue(), '');
+
+ await open('/login?no-reset=1');
+ assert.equal(
+ await page.getByRole('link', { name: 'Forgot password?' }).count(),
+ 0,
+ );
+ await credentials();
+ await outcome('challenge');
+ await page.getByRole('button', { name: 'Sign in', exact: true }).click();
+ await page.locator('#code').waitFor();
+ assert.equal(
+ await page.locator('#code').getAttribute('inputmode'),
+ 'numeric',
+ );
+ assert.equal(
+ await page.locator('#code').getAttribute('autocomplete'),
+ 'one-time-code',
+ );
+ await page.locator('#code').evaluate((el) => {
+ const clipboardData = new DataTransfer();
+ clipboardData.setData('text', '012 345');
+ el.dispatchEvent(
+ new ClipboardEvent('paste', {
+ clipboardData,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ });
+ assert.equal(await page.locator('#code').inputValue(), '012345');
+ await outcome('validation');
+ await page.locator('#code').press('Enter');
+ await state('validation');
+ assert.deepEqual(submissions.at(-1).data, { code: '012345' });
+ assert.equal(await page.locator('#code').inputValue(), '012345');
+ await page
+ .getByRole('button', { name: 'Use a recovery code instead' })
+ .click();
+ assert.equal(
+ await page
+ .locator('#recovery_code')
+ .evaluate((el) => el === document.activeElement),
+ true,
+ );
+ assert.equal(await page.getByRole('alert').count(), 0);
+ await page.locator('#recovery_code').fill('fixture-recovery-code');
+ await page.locator('#recovery_code').press('Enter');
+ await state('validation');
+ assert.deepEqual(submissions.at(-1).data, {
+ recovery_code: 'fixture-recovery-code',
+ });
+ assert.equal(
+ await page.locator('#recovery_code').getAttribute('aria-invalid'),
+ 'true',
+ );
+ assert.equal(
+ await page.locator('#recovery_code').inputValue(),
+ 'fixture-recovery-code',
+ );
+ await noStoredSecrets();
+ await page
+ .getByRole('button', { name: 'Use an authenticator code instead' })
+ .click();
+ assert.equal(await page.locator('#code').inputValue(), '');
+ await page
+ .getByRole('button', { name: 'Use a recovery code instead' })
+ .click();
+ assert.equal(await page.locator('#recovery_code').inputValue(), '');
+ await page.locator('#recovery_code').fill('fixture-recovery-code');
+ await outcome('server');
+ await page.getByRole('button', { name: 'Verify and sign in' }).click();
+ await state('server');
+ assert.equal(
+ await page.locator('#recovery_code').inputValue(),
+ 'fixture-recovery-code',
+ );
+ await outcome('success');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await page
+ .getByRole('heading', { name: 'Signed in destination' })
+ .waitFor();
+ await noStoredSecrets();
+
+ await open('/email/verify');
+ await page.getByText('alex.morgan@example.test', { exact: true }).waitFor();
+ await page
+ .getByRole('button', { name: 'Resend verification email', exact: true })
+ .click();
+ await page.getByText('Verification link sent', { exact: true }).waitFor();
+ assert.equal(submissions.at(-1).path, '/email/verification-notification');
+ await outcome('limited');
+ await page
+ .getByRole('button', { name: 'Resend verification email', exact: true })
+ .click();
+ await page.getByText('Too many attempts', { exact: true }).waitFor();
+ await outcome('success');
+ await page.getByRole('button', { name: 'Continue to workspace' }).click();
+ await page
+ .getByRole('heading', { name: 'Signed in destination' })
+ .waitFor();
+ await open('/email/verify');
+ await page.getByRole('button', { name: 'Sign out', exact: true }).click();
+ await page.locator('#email').waitFor();
+ assert.equal(submissions.at(-1).path, '/logout');
+ assert.deepEqual(errors, []);
+ console.log(
+ 'Auth browser checks passed: themes, responsive layout, keyboard, autofill, retention, retry, expiry, throttling, 2FA, recovery, verification and logout.',
+ );
+} finally {
+ await browser.close();
+}
diff --git a/tests/Fixtures/auth-preview.mjs b/tests/Fixtures/auth-preview.mjs
new file mode 100644
index 0000000..195e945
--- /dev/null
+++ b/tests/Fixtures/auth-preview.mjs
@@ -0,0 +1,20 @@
+import { createApp, h } from 'vue';
+import { createInertiaApp } from '@inertiajs/vue3';
+import '../../resources/css/app.css';
+import Login from '../../resources/js/pages/auth/Login.vue';
+import VerifyEmail from '../../resources/js/pages/auth/VerifyEmail.vue';
+import TwoFactorChallenge from '../../resources/js/pages/auth/TwoFactorChallenge.vue';
+import { createAppearance } from '../../resources/js/composables/useAppearance';
+import { createLocalization } from '../../resources/js/lib/localization';
+
+const Destination = { render: () => h('h1', 'Signed in destination') };
+createInertiaApp({
+ resolve: (name) =>
+ ({ Login, VerifyEmail, TwoFactorChallenge, Destination })[name],
+ setup: ({ el, App, props, plugin }) =>
+ createApp({ render: () => h(App, props) })
+ .use(plugin)
+ .use(createLocalization())
+ .use(createAppearance())
+ .mount(el),
+});
diff --git a/tests/Fixtures/auth-server.mjs b/tests/Fixtures/auth-server.mjs
new file mode 100644
index 0000000..5c0cd58
--- /dev/null
+++ b/tests/Fixtures/auth-server.mjs
@@ -0,0 +1,119 @@
+import { fileURLToPath } from 'node:url';
+import vue from '@vitejs/plugin-vue';
+import tailwind from '@tailwindcss/vite';
+import { createServer } from 'vite';
+
+// Isolated Inertia transport fixture; real Fortify sessions are covered by PHPUnit.
+const server = await createServer({
+ configFile: false,
+ cacheDir: 'node_modules/.vite/auth-fixture',
+ plugins: [vue(), tailwind()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
+ },
+ },
+ server: { host: '127.0.0.1', port: 4177, strictPort: true, hmr: false },
+ appType: 'custom',
+});
+
+server.middlewares.use(async (request, response, next) => {
+ const url = new URL(request.url, 'http://localhost');
+ const pages = {
+ '/login': 'Login',
+ '/two-factor-challenge': 'TwoFactorChallenge',
+ '/email/verify': 'VerifyEmail',
+ '/email/verification-notification': 'VerifyEmail',
+ '/dashboard': 'Destination',
+ '/logout': 'Login',
+ '/forgot-password': 'Destination',
+ '/': 'Destination',
+ };
+ if (!(url.pathname in pages)) {
+ return next();
+ }
+ const mutation = request.method === 'POST';
+ const result =
+ request.headers.cookie?.match(/auth_test_result=(\w+)/)?.[1] ??
+ 'success';
+ if (mutation && ['server', 'expired', 'limited'].includes(result)) {
+ response.statusCode = { server: 503, expired: 419, limited: 429 }[
+ result
+ ];
+ response.setHeader('Content-Type', 'text/html');
+ response.end('
Simulated failed response
');
+ return;
+ }
+ let body = '';
+ if (mutation) {
+ for await (const chunk of request) {
+ body += chunk;
+ }
+ }
+ const submitted = body ? JSON.parse(body) : {};
+ let path = url.pathname;
+ let errors = {};
+ let status;
+ if (mutation && result === 'validation') {
+ path = new URL(request.headers.referer).pathname;
+ const field =
+ path === '/login'
+ ? 'email'
+ : 'recovery_code' in submitted
+ ? 'recovery_code'
+ : 'code';
+ errors = { [field]: 'These details could not be verified.' };
+ } else if (mutation) {
+ if (path === '/email/verification-notification') {
+ path = '/email/verify';
+ status = 'verification-link-sent';
+ } else if (path === '/logout') {
+ path = '/login';
+ } else {
+ path =
+ result === 'challenge' ? '/two-factor-challenge' : '/dashboard';
+ }
+ }
+ const page = {
+ component: pages[path],
+ url: path,
+ version: null,
+ clearHistory: false,
+ encryptHistory: false,
+ props: {
+ name: 'KanbanGit',
+ errors,
+ status,
+ canResetPassword: !url.searchParams.has('no-reset'),
+ auth: {
+ user:
+ path === '/email/verify'
+ ? {
+ id: 1,
+ name: 'Alex Morgan',
+ email: 'alex.morgan@example.test',
+ }
+ : null,
+ },
+ },
+ };
+ response.setHeader('Vary', 'X-Inertia');
+ if (request.headers['x-inertia']) {
+ response.setHeader('X-Inertia', 'true');
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify(page));
+ return;
+ }
+ const escaped = JSON.stringify(page)
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<');
+ const html =
+ '
';
+ response.setHeader('Content-Type', 'text/html');
+ response.end(await server.transformIndexHtml(request.url, html));
+});
+await server.listen();
+console.log('Auth fixture ready at http://127.0.0.1:4177/login');
diff --git a/tests/Fixtures/form-feedback.mjs b/tests/Fixtures/form-feedback.mjs
index 10aa11b..da64cfa 100644
--- a/tests/Fixtures/form-feedback.mjs
+++ b/tests/Fixtures/form-feedback.mjs
@@ -69,6 +69,12 @@ try {
const { default: InputError } = await server.ssrLoadModule(
'/resources/js/components/InputError.vue',
);
+ const { default: AuthFormFeedback } = await server.ssrLoadModule(
+ '/resources/js/components/AuthFormFeedback.vue',
+ );
+ const { default: PasswordInput } = await server.ssrLoadModule(
+ '/resources/js/components/PasswordInput.vue',
+ );
const localization = createLocalization(
{ locale: 'de' },
{
@@ -76,6 +82,8 @@ try {
...en,
'common.retry': 'Erneut versuchen',
'feedback.empty': 'Noch keine Einträge',
+ 'auth.feedback.expiredTitle': 'Sitzung abgelaufen',
+ 'auth.password.show': 'Passwort anzeigen',
},
},
);
@@ -123,6 +131,40 @@ try {
assert.match(fieldError, /role="alert"/);
assert.match(fieldError, /text-destructive/);
assert.doesNotMatch(fieldError, /