Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/Http/Middleware/SetLocaleFromHeader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;

class SetLocaleFromHeader
{
/**
* Handle an incoming request and set the application locale based on Accept-Language header.
*
* The middleware reads the Accept-Language header from the request and sets the application
* locale accordingly. If no header is present or the language is not supported, it defaults
* to the application's configured locale.
*
* Supported locales: en (English), de (German)
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$supportedLocales = ['en', 'de'];

// Get preferred language from Accept-Language header
$locale = $request->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);
}
}
5 changes: 5 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions lang/.translation_io
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"timestamp":1762116846}
2 changes: 2 additions & 0 deletions lang/.translation_io.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 SecPal Contributors
SPDX-License-Identifier: CC0-1.0
13 changes: 13 additions & 0 deletions lang/de.json
Original file line number Diff line number Diff line change
@@ -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:"
}
2 changes: 2 additions & 0 deletions lang/de.json.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 SecPal Contributors
SPDX-License-Identifier: CC0-1.0
22 changes: 11 additions & 11 deletions resources/views/emails/password-reset.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,30 @@
--}}

<x-mail::message>
# 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]) }}

<x-mail::button :url="$resetUrl">
Reset Password
{{ __('Reset Password') }}
</x-mail::button>

**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,<br>
{{ __('Thanks') }},<br>
{{ config('app.name') }}

---

<small>
If you're having trouble clicking the "Reset Password" button, copy and paste the URL below into your web browser:<br>
{{ __('If you\'re having trouble clicking the ":button" button, copy and paste the URL below into your web browser:', ['button' => __('Reset Password')]) }}<br>
{{ $resetUrl }}
</small>
</x-mail::message>
58 changes: 58 additions & 0 deletions tests/Feature/LocaleMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

use Illuminate\Support\Facades\App;

test('middleware sets locale from Accept-Language header to German', function (): void {
$response = $this->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();
});
56 changes: 56 additions & 0 deletions tests/Feature/PasswordResetEmailLocalizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

use Illuminate\Support\Facades\App;

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:')
->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');
});