diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c05716..d4d71bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **User Language Preference** (#86) + - New `preferred_locale` column in `users` table (VARCHAR(5), nullable) + - PATCH `/v1/me/language` endpoint to update user's preferred language + - Supports `en` (English) and `de` (German) + - Can be set to `null` to use default/Accept-Language header + - Form request validation via `UpdateUserLanguageRequest` + - 8 comprehensive feature tests + - Database migration: `2025_11_16_192506_add_preferred_locale_to_users_table` + - **Secret Sharing & Access Control (Phase 3)** (#182) - **Secret CRUD API**: Full REST API for password manager functionality - Create secrets with encrypted title, username, password, URL, notes (POST `/v1/secrets`) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 3af3d0c..d1ad248 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -8,6 +8,7 @@ use App\Http\Requests\PasswordResetRequest; use App\Http\Requests\PasswordResetRequestRequest; use App\Http\Requests\TokenRequest; +use App\Http\Requests\UpdateUserLanguageRequest; use App\Mail\PasswordResetMail; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -103,6 +104,28 @@ public function me(Request $request): JsonResponse ]); } + /** + * Update the authenticated user's language preference. + */ + public function updateLanguage(UpdateUserLanguageRequest $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + /** @var array{locale: string|null} $validated */ + $validated = $request->validated(); + + $user->update([ + 'preferred_locale' => $validated['locale'], + ]); + + return response()->json([ + 'data' => [ + 'preferred_locale' => $user->preferred_locale, + ], + ]); + } + /** * Request a password reset email. * diff --git a/app/Http/Requests/UpdateUserLanguageRequest.php b/app/Http/Requests/UpdateUserLanguageRequest.php new file mode 100644 index 0000000..b898169 --- /dev/null +++ b/app/Http/Requests/UpdateUserLanguageRequest.php @@ -0,0 +1,50 @@ +|string> + */ + public function rules(): array + { + return [ + 'locale' => [ + 'present', + 'nullable', + Rule::in(['en', 'de']), + ], + ]; + } + + /** + * Get the custom error messages for validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'locale.in' => 'Language must be either English (en) or German (de).', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e4759fb..a195382 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,6 +27,7 @@ * @property string $password * @property ?\Illuminate\Support\Carbon $email_verified_at * @property string|null $remember_token + * @property string|null $preferred_locale * @property \Illuminate\Support\Carbon $created_at * @property \Illuminate\Support\Carbon $updated_at */ @@ -52,6 +53,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'preferred_locale', ]; /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index d40b7ac..929d76b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -32,6 +32,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'preferred_locale' => null, ]; } diff --git a/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php b/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php new file mode 100644 index 0000000..8f4774b --- /dev/null +++ b/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php @@ -0,0 +1,33 @@ +string('preferred_locale', 5)->nullable()->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('preferred_locale'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index f76c4ca..58453b2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -48,6 +48,7 @@ Route::post('/auth/logout', [AuthController::class, 'logout']); Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']); Route::get('/me', [AuthController::class, 'me']); + Route::patch('/me/language', [AuthController::class, 'updateLanguage']); // Role Management CRUD API // Authorization: Route-level permission middleware + Policy (defense-in-depth) diff --git a/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php b/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php new file mode 100644 index 0000000..efb1075 --- /dev/null +++ b/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php @@ -0,0 +1,119 @@ +create(['preferred_locale' => null]); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertOk() + ->assertJson([ + 'data' => [ + 'preferred_locale' => 'de', + ], + ]); + + expect($user->fresh()->preferred_locale)->toBe('de'); + }); + + test('user can update their language preference to English', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'en', + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBe('en'); + }); + + test('user can reset language preference to null', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => null, + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBeNull(); + }); + + test('validation rejects invalid locale codes', function () { + /** @var User $user */ + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'fr', // French not supported + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['locale']); + }); + + test('empty string is treated as null and accepted', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => '', + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBeNull(); + }); + + test('validation requires locale field', function () { + /** @var User $user */ + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['locale']); + }); + + test('unauthenticated users cannot update language preference', function () { + $response = $this->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertStatus(401); + }); + + test('endpoint only affects authenticated user', function () { + /** @var User $user1 */ + $user1 = User::factory()->create(['preferred_locale' => null]); + /** @var User $user2 */ + $user2 = User::factory()->create(['preferred_locale' => null]); + + $response = $this->actingAs($user1) + ->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertOk(); + expect($user1->fresh()->preferred_locale)->toBe('de'); + expect($user2->fresh()->preferred_locale)->toBeNull(); + }); +});