diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e28f53..f105cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- German translations for password reset emails +- JSON-based translation files (`lang/de.json`) for German language support +- Localized password reset email template using `__()` helper functions +- 5 comprehensive tests for password reset email translations +- `SetLocaleFromHeader` middleware for Accept-Language header detection +- Automatic locale switching based on HTTP Accept-Language header +- Support for multi-language API responses (English, German) +- 6 comprehensive tests for locale middleware functionality - Translation.io integration for multi-language support (en, de) - Configuration file `config/translation.php` for Translation.io - `TRANSLATIONIO_KEY` environment variable for API key management diff --git a/app/Http/Middleware/SetLocaleFromHeader.php b/app/Http/Middleware/SetLocaleFromHeader.php new file mode 100644 index 0000000..43316da --- /dev/null +++ b/app/Http/Middleware/SetLocaleFromHeader.php @@ -0,0 +1,43 @@ +getPreferredLanguage($supportedLocales); + + // Fall back to default if no match + if ($locale === null) { + $defaultLocale = config('app.locale'); + $locale = is_string($defaultLocale) ? $defaultLocale : 'en'; + } + + App::setLocale($locale); + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 405de00..f8dcb07 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,11 @@ 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, ]); + // Apply SetLocaleFromHeader middleware to all API routes + $middleware->api(append: [ + \App\Http\Middleware\SetLocaleFromHeader::class, + ]); + // Define rate limiters (using cache, not Redis) RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); diff --git a/lang/.translation_io b/lang/.translation_io new file mode 100644 index 0000000..ee0aa7d --- /dev/null +++ b/lang/.translation_io @@ -0,0 +1 @@ +{"timestamp":1762116846} diff --git a/lang/.translation_io.license b/lang/.translation_io.license new file mode 100644 index 0000000..4f63a14 --- /dev/null +++ b/lang/.translation_io.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 SecPal Contributors +SPDX-License-Identifier: CC0-1.0 diff --git a/lang/de.json b/lang/de.json new file mode 100644 index 0000000..96e012f --- /dev/null +++ b/lang/de.json @@ -0,0 +1,13 @@ +{ + "Reset Your Password": "Passwort zurücksetzen", + "Hello": "Hallo", + "You requested a password reset for your SecPal account.": "Sie haben eine Passwort-Zurücksetzung für Ihr SecPal-Konto angefordert.", + "Click the button below to reset your password. This link will expire in :minutes minutes.": "Klicken Sie auf die Schaltfläche unten, um Ihr Passwort zurückzusetzen. Dieser Link läuft in :minutes Minuten ab.", + "Reset Password": "Passwort zurücksetzen", + "Security Notice:": "Sicherheitshinweis:", + "Never share this link with anyone": "Teilen Sie diesen Link niemals mit anderen", + "If you didn't request this reset, please ignore this email": "Wenn Sie diese Zurücksetzung nicht angefordert haben, ignorieren Sie diese E-Mail bitte", + "Your password will remain unchanged until you complete the reset process": "Ihr Passwort bleibt unverändert, bis Sie den Zurücksetzungsvorgang abschließen", + "Thanks": "Vielen Dank", + "If you're having trouble clicking the \":button\" button, copy and paste the URL below into your web browser:": "Falls Sie Probleme beim Klicken auf die Schaltfläche \":button\" haben, kopieren Sie die folgende URL und fügen Sie sie in Ihren Webbrowser ein:" +} diff --git a/lang/de.json.license b/lang/de.json.license new file mode 100644 index 0000000..4f63a14 --- /dev/null +++ b/lang/de.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 SecPal Contributors +SPDX-License-Identifier: CC0-1.0 diff --git a/resources/views/emails/password-reset.blade.php b/resources/views/emails/password-reset.blade.php index b6bb860..f652301 100644 --- a/resources/views/emails/password-reset.blade.php +++ b/resources/views/emails/password-reset.blade.php @@ -4,30 +4,30 @@ --}} -# Reset Your Password +# {{ __('Reset Your Password') }} -Hello {{ $user->name }}, +{{ __('Hello') }} {{ $user->name }}, -You requested a password reset for your SecPal account. +{{ __('You requested a password reset for your SecPal account.') }} -Click the button below to reset your password. This link will expire in **{{ $expiresInMinutes }} minutes**. +{{ __('Click the button below to reset your password. This link will expire in :minutes minutes.', ['minutes' => $expiresInMinutes]) }} -Reset Password +{{ __('Reset Password') }} -**Security Notice:** -- Never share this link with anyone -- If you didn't request this reset, please ignore this email -- Your password will remain unchanged until you complete the reset process +**{{ __('Security Notice:') }}** +- {{ __('Never share this link with anyone') }} +- {{ __('If you didn\'t request this reset, please ignore this email') }} +- {{ __('Your password will remain unchanged until you complete the reset process') }} -Thanks,
+{{ __('Thanks') }},
{{ config('app.name') }} --- -If you're having trouble clicking the "Reset Password" button, copy and paste the URL below into your web browser:
+{{ __('If you\'re having trouble clicking the ":button" button, copy and paste the URL below into your web browser:', ['button' => __('Reset Password')]) }}
{{ $resetUrl }}
diff --git a/tests/Feature/LocaleMiddlewareTest.php b/tests/Feature/LocaleMiddlewareTest.php new file mode 100644 index 0000000..898544f --- /dev/null +++ b/tests/Feature/LocaleMiddlewareTest.php @@ -0,0 +1,58 @@ +withHeaders([ + 'Accept-Language' => 'de', + ])->get('/api/health'); + + expect(App::getLocale())->toBe('de'); + $response->assertOk(); +}); + +test('middleware sets locale from Accept-Language header to English', function (): void { + $response = $this->withHeaders([ + 'Accept-Language' => 'en', + ])->get('/api/health'); + + expect(App::getLocale())->toBe('en'); + $response->assertOk(); +}); + +test('middleware defaults to configured locale when no Accept-Language header', function (): void { + $response = $this->get('/api/health'); + + expect(App::getLocale())->toBe(config('app.locale')); + $response->assertOk(); +}); + +test('middleware defaults to configured locale for unsupported language', function (): void { + $response = $this->withHeaders([ + 'Accept-Language' => 'fr', + ])->get('/api/health'); + + expect(App::getLocale())->toBe(config('app.locale')); + $response->assertOk(); +}); + +test('middleware handles complex Accept-Language header with quality values', function (): void { + $response = $this->withHeaders([ + 'Accept-Language' => 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', + ])->get('/api/health'); + + expect(App::getLocale())->toBe('de'); + $response->assertOk(); +}); + +test('middleware prefers higher quality language from Accept-Language header', function (): void { + $response = $this->withHeaders([ + 'Accept-Language' => 'en;q=0.5,de;q=0.9', + ])->get('/api/health'); + + expect(App::getLocale())->toBe('de'); + $response->assertOk(); +}); diff --git a/tests/Feature/PasswordResetEmailLocalizationTest.php b/tests/Feature/PasswordResetEmailLocalizationTest.php new file mode 100644 index 0000000..e3fb6de --- /dev/null +++ b/tests/Feature/PasswordResetEmailLocalizationTest.php @@ -0,0 +1,56 @@ +toBe('Reset Your Password') + ->and(__('Hello'))->toBe('Hello') + ->and(__('Security Notice:'))->toBe('Security Notice:') + ->and(__('Thanks'))->toBe('Thanks') + ->and(__('Reset Password'))->toBe('Reset Password'); +}); + +test('password reset translation strings exist in German', function (): void { + App::setLocale('de'); + + expect(__('Reset Your Password'))->toBe('Passwort zurücksetzen') + ->and(__('Hello'))->toBe('Hallo') + ->and(__('Security Notice:'))->toBe('Sicherheitshinweis:') + ->and(__('Thanks'))->toBe('Vielen Dank') + ->and(__('Reset Password'))->toBe('Passwort zurücksetzen'); +}); + +test('password reset translation with parameters works in English', function (): void { + App::setLocale('en'); + + $translated = __('Click the button below to reset your password. This link will expire in :minutes minutes.', ['minutes' => 60]); + + expect($translated)->toContain('60 minutes') + ->and($translated)->toContain('Click the button'); +}); + +test('password reset translation with parameters works in German', function (): void { + App::setLocale('de'); + + $translated = __('Click the button below to reset your password. This link will expire in :minutes minutes.', ['minutes' => 60]); + + expect($translated)->toContain('60 Minuten') + ->and($translated)->toContain('Klicken Sie auf die Schaltfläche'); +}); + +test('password reset button text translation with parameters works', function (): void { + App::setLocale('de'); + + $translated = __('If you\'re having trouble clicking the ":button" button, copy and paste the URL below into your web browser:', [ + 'button' => __('Reset Password'), + ]); + + expect($translated) + ->toContain('Passwort zurücksetzen') + ->toContain('kopieren Sie die folgende URL'); +});