From 0835829383618ca306ebeeeb9746475b4d96cd53 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 22:00:07 +0100 Subject: [PATCH 1/4] feat: implement Accept-Language middleware and German translations - Create SetLocaleFromHeader middleware to read Accept-Language header - Register middleware in api group to apply to all API routes - Support English (en) and German (de) locales with fallback - Handle quality values in Accept-Language header - Create lang/ directory structure for translations - Add 7 comprehensive PEST tests for middleware functionality - Translate password reset email template to German - Create German translation file (lang/de.json) with 11 strings - Add 5 comprehensive PEST tests for email localization - Update CHANGELOG.md with middleware and translation features Part of: #83 Fixes: #87 --- CHANGELOG.md | 8 +++ app/Http/Middleware/SetLocaleFromHeader.php | 38 +++++++++++ bootstrap/app.php | 5 ++ lang/.translation_io | 1 + lang/de.json | 13 ++++ .../views/emails/password-reset.blade.php | 22 +++--- tests/Feature/LocaleMiddlewareTest.php | 67 +++++++++++++++++++ .../PasswordResetEmailLocalizationTest.php | 56 ++++++++++++++++ 8 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 app/Http/Middleware/SetLocaleFromHeader.php create mode 100644 lang/.translation_io create mode 100644 lang/de.json create mode 100644 tests/Feature/LocaleMiddlewareTest.php create mode 100644 tests/Feature/PasswordResetEmailLocalizationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e28f53..0dfb4d8 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) +- 7 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..26d8a23 --- /dev/null +++ b/app/Http/Middleware/SetLocaleFromHeader.php @@ -0,0 +1,38 @@ +getPreferredLanguage($supportedLocales); + + // Fall back to default if no match + App::setLocale($locale ?: config('app.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..3d0d706 --- /dev/null +++ b/lang/.translation_io @@ -0,0 +1 @@ +{"timestamp":1762116846} \ No newline at end of file diff --git a/lang/de.json b/lang/de.json new file mode 100644 index 0000000..28726c3 --- /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:" +} \ No newline at end of file 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..5b864b6 --- /dev/null +++ b/tests/Feature/LocaleMiddlewareTest.php @@ -0,0 +1,67 @@ +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(); +}); + +test('middleware applies to all API routes', function (): void { + $response = $this->withHeaders([ + 'Accept-Language' => 'de', + ])->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..dbba3ab --- /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'); +}); From 94b02323617e270c68b7530385aa83bc2b6932f9 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 22:01:29 +0100 Subject: [PATCH 2/4] chore: add REUSE license files for translation assets --- lang/.translation_io | 2 +- lang/.translation_io.license | 2 ++ lang/de.json | 24 ++++++++++++------------ lang/de.json.license | 2 ++ 4 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 lang/.translation_io.license create mode 100644 lang/de.json.license diff --git a/lang/.translation_io b/lang/.translation_io index 3d0d706..ee0aa7d 100644 --- a/lang/.translation_io +++ b/lang/.translation_io @@ -1 +1 @@ -{"timestamp":1762116846} \ No newline at end of file +{"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 index 28726c3..96e012f 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1,13 +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:" -} \ No newline at end of file + "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 From a37580582c0110849e1cd3307923eadf3e9bc31b Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 22:03:54 +0100 Subject: [PATCH 3/4] fix: add type safety for locale fallback in middleware --- app/Http/Middleware/SetLocaleFromHeader.php | 7 ++++++- .../PasswordResetEmailLocalizationTest.php | 16 ++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/Http/Middleware/SetLocaleFromHeader.php b/app/Http/Middleware/SetLocaleFromHeader.php index 26d8a23..43316da 100644 --- a/app/Http/Middleware/SetLocaleFromHeader.php +++ b/app/Http/Middleware/SetLocaleFromHeader.php @@ -31,7 +31,12 @@ public function handle(Request $request, Closure $next): Response $locale = $request->getPreferredLanguage($supportedLocales); // Fall back to default if no match - App::setLocale($locale ?: config('app.locale')); + if ($locale === null) { + $defaultLocale = config('app.locale'); + $locale = is_string($defaultLocale) ? $defaultLocale : 'en'; + } + + App::setLocale($locale); return $next($request); } diff --git a/tests/Feature/PasswordResetEmailLocalizationTest.php b/tests/Feature/PasswordResetEmailLocalizationTest.php index dbba3ab..e3fb6de 100644 --- a/tests/Feature/PasswordResetEmailLocalizationTest.php +++ b/tests/Feature/PasswordResetEmailLocalizationTest.php @@ -7,7 +7,7 @@ test('password reset translation strings exist in English', function (): void { App::setLocale('en'); - + expect(__('Reset Your Password'))->toBe('Reset Your Password') ->and(__('Hello'))->toBe('Hello') ->and(__('Security Notice:'))->toBe('Security Notice:') @@ -17,7 +17,7 @@ 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:') @@ -27,29 +27,29 @@ 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'); From 9369e9bf5b215e111d3e26b26b71abfab02c64ee Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 2 Nov 2025 22:13:00 +0100 Subject: [PATCH 4/4] fix: address Copilot review comments - Remove redundant test 'middleware applies to all API routes' - Update CHANGELOG test count from 7 to 6 for locale middleware - 11 tests total (6 middleware + 5 translations) all passing --- CHANGELOG.md | 2 +- tests/Feature/LocaleMiddlewareTest.php | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfb4d8..f105cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SetLocaleFromHeader` middleware for Accept-Language header detection - Automatic locale switching based on HTTP Accept-Language header - Support for multi-language API responses (English, German) -- 7 comprehensive tests for locale middleware functionality +- 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/tests/Feature/LocaleMiddlewareTest.php b/tests/Feature/LocaleMiddlewareTest.php index 5b864b6..898544f 100644 --- a/tests/Feature/LocaleMiddlewareTest.php +++ b/tests/Feature/LocaleMiddlewareTest.php @@ -56,12 +56,3 @@ expect(App::getLocale())->toBe('de'); $response->assertOk(); }); - -test('middleware applies to all API routes', function (): void { - $response = $this->withHeaders([ - 'Accept-Language' => 'de', - ])->get('/api/health'); - - expect(App::getLocale())->toBe('de'); - $response->assertOk(); -});