From 8e5a50dd2b583d9163d703e458a2c44a0b8f35a4 Mon Sep 17 00:00:00 2001
From: Leon Morival
Date: Thu, 21 May 2026 21:32:20 +0200
Subject: [PATCH] feat: locale
---
.../Resources/Users/Schemas/UserForm.php | 19 +++++++
app/Http/Controllers/AuthController.php | 20 ++++++-
app/Http/Requests/LoginRequest.php | 18 +++++++
app/Http/Requests/RegisterRequest.php | 17 ++++++
app/Http/Requests/UpdateUserRequest.php | 17 ++++++
app/Http/Resources/UserResource.php | 1 +
app/Mail/ResetPasswordMail.php | 8 ++-
app/Mail/VerifyAccount.php | 8 ++-
app/Models/User.php | 9 +++-
database/factories/UserFactory.php | 1 +
...05_21_192621_add_locale_to_users_table.php | 28 ++++++++++
lang/en/admin.php | 5 ++
lang/en/mail.php | 36 +++++++++++++
lang/fr/admin.php | 5 ++
lang/fr/mail.php | 36 +++++++++++++
resources/views/auth/reset-password.blade.php | 18 +++----
.../views/emails/reset-password.blade.php | 18 +++----
.../views/emails/verify-account.blade.php | 18 +++----
tests/Feature/EmailVerificationTest.php | 27 +++++++++-
tests/Feature/PasswordResetTest.php | 54 +++++++++++++++++++
tests/Feature/UserNutritionGoalsTest.php | 6 +++
21 files changed, 337 insertions(+), 32 deletions(-)
create mode 100644 database/migrations/2026_05_21_192621_add_locale_to_users_table.php
create mode 100644 lang/en/mail.php
create mode 100644 lang/fr/mail.php
diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php
index 32bcd91..6160188 100644
--- a/app/Filament/Resources/Users/Schemas/UserForm.php
+++ b/app/Filament/Resources/Users/Schemas/UserForm.php
@@ -35,6 +35,11 @@ class UserForm
->required()
->unique(ignoreRecord: true)
->maxLength(255),
+ Select::make('locale')
+ ->label(__('admin.users.fields.locale'))
+ ->options(self::localeOptions())
+ ->default('en')
+ ->required(),
FileUpload::make('avatar_url')
->label(__('admin.users.fields.avatar'))
->image()
@@ -136,4 +141,18 @@ class UserForm
]),
]);
}
+
+ /**
+ * @return array
+ */
+ private static function localeOptions(): array
+ {
+ $labels = __('admin.users.locales');
+
+ return collect(config('app.supported_locales', ['fr', 'en']))
+ ->mapWithKeys(fn (string $locale): array => [
+ $locale => is_array($labels) ? ($labels[$locale] ?? $locale) : $locale,
+ ])
+ ->all();
+ }
}
diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
index 1f2912e..7c90f5b 100644
--- a/app/Http/Controllers/AuthController.php
+++ b/app/Http/Controllers/AuthController.php
@@ -65,6 +65,10 @@ class AuthController extends Controller
], 401);
}
+ if (isset($data['locale']) && $user->locale !== $data['locale']) {
+ $user->forceFill(['locale' => $data['locale']])->save();
+ }
+
if (! $user->hasVerifiedEmail()) {
return response()->json([
'message' => __('api.auth.email_not_verified'),
@@ -162,9 +166,13 @@ class AuthController extends Controller
public function showResetPasswordForm(Request $request, string $token): View
{
+ $email = (string) $request->query('email', '');
+ $locale = $this->localeForEmail($email);
+
return view('auth.reset-password', [
'appName' => config('app.name'),
- 'email' => (string) $request->query('email', ''),
+ 'email' => $email,
+ 'locale' => $locale,
'passwordReset' => false,
'token' => $token,
]);
@@ -172,6 +180,9 @@ class AuthController extends Controller
public function resetPasswordFromView(ResetPasswordRequest $request): RedirectResponse|View
{
+ $locale = $this->localeForEmail((string) $request->validated('email'));
+ app()->setLocale($locale);
+
$status = $this->resetPasswordFor($request->safe());
if ($status !== Password::PASSWORD_RESET) {
@@ -183,6 +194,7 @@ class AuthController extends Controller
return view('auth.reset-password', [
'appName' => config('app.name'),
'email' => strtolower($request->validated('email')),
+ 'locale' => $locale,
'passwordReset' => true,
'token' => '',
]);
@@ -298,6 +310,11 @@ class AuthController extends Controller
);
}
+ private function localeForEmail(string $email): string
+ {
+ return User::where('email', strtolower($email))->value('locale') ?: 'en';
+ }
+
/**
* @param array $data
* @return array
@@ -307,6 +324,7 @@ class AuthController extends Controller
$attributes = Arr::only($data, [
'name',
'avatar_url',
+ 'locale',
'height',
'bio',
'sex',
diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php
index 422ec00..8a735e7 100644
--- a/app/Http/Requests/LoginRequest.php
+++ b/app/Http/Requests/LoginRequest.php
@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
class LoginRequest extends FormRequest
{
@@ -23,7 +24,24 @@ class LoginRequest extends FormRequest
{
return [
'email' => ['required', 'string'],
+ 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string'],
];
}
+
+ protected function prepareForValidation(): void
+ {
+ if ($this->has('locale')) {
+ $this->merge([
+ 'locale' => $this->normalizeLocale((string) $this->input('locale')),
+ ]);
+ }
+ }
+
+ private function normalizeLocale(string $locale): string
+ {
+ $locale = strtolower(str_replace('_', '-', $locale));
+
+ return explode('-', $locale)[0];
+ }
}
diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php
index a38c1da..f0e77c9 100644
--- a/app/Http/Requests/RegisterRequest.php
+++ b/app/Http/Requests/RegisterRequest.php
@@ -37,6 +37,7 @@ class RegisterRequest extends FormRequest
'unique:users,name',
],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
+ 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string', 'min:8'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'bio' => ['nullable', 'string'],
@@ -56,4 +57,20 @@ class RegisterRequest extends FormRequest
'avatar.max' => __('api.validation.image_max_2mb'),
];
}
+
+ protected function prepareForValidation(): void
+ {
+ if ($this->has('locale')) {
+ $this->merge([
+ 'locale' => $this->normalizeLocale((string) $this->input('locale')),
+ ]);
+ }
+ }
+
+ private function normalizeLocale(string $locale): string
+ {
+ $locale = strtolower(str_replace('_', '-', $locale));
+
+ return explode('-', $locale)[0];
+ }
}
diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php
index a2ce50c..3716ed3 100644
--- a/app/Http/Requests/UpdateUserRequest.php
+++ b/app/Http/Requests/UpdateUserRequest.php
@@ -21,6 +21,7 @@ class UpdateUserRequest extends FormRequest
return [
'bio' => ['sometimes', 'nullable', 'string'],
'height' => ['sometimes', 'nullable', 'integer'],
+ 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'nutritionGoals' => ['sometimes', 'array'],
'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'],
@@ -46,4 +47,20 @@ class UpdateUserRequest extends FormRequest
'nutritionGoals.*.max' => __('api.validation.nutrition_goals_max'),
];
}
+
+ protected function prepareForValidation(): void
+ {
+ if ($this->has('locale')) {
+ $this->merge([
+ 'locale' => $this->normalizeLocale((string) $this->input('locale')),
+ ]);
+ }
+ }
+
+ private function normalizeLocale(string $locale): string
+ {
+ $locale = strtolower(str_replace('_', '-', $locale));
+
+ return explode('-', $locale)[0];
+ }
}
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
index 441cf79..7a5ed4d 100644
--- a/app/Http/Resources/UserResource.php
+++ b/app/Http/Resources/UserResource.php
@@ -18,6 +18,7 @@ class UserResource extends JsonResource
return [
'name' => $this->name,
'email' => $this->email,
+ 'locale' => $this->locale,
'emailVerified' => $this->hasVerifiedEmail(),
'emailVerifiedAt' => $this->email_verified_at?->toISOString(),
'height' => $this->height,
diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php
index e95279b..513b666 100644
--- a/app/Mail/ResetPasswordMail.php
+++ b/app/Mail/ResetPasswordMail.php
@@ -27,7 +27,7 @@ class ResetPasswordMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
- subject: 'Réinitialise ton mot de passe Bowli',
+ subject: trans('mail.reset_password.subject', [], $this->mailLocale()),
);
}
@@ -40,6 +40,7 @@ class ResetPasswordMail extends Mailable
view: 'emails.reset-password',
with: [
'expiresInMinutes' => (int) config('auth.passwords.users.expire', 60),
+ 'locale' => $this->mailLocale(),
'resetUrl' => $this->resetUrl,
'userName' => $this->user->name,
],
@@ -55,4 +56,9 @@ class ResetPasswordMail extends Mailable
{
return [];
}
+
+ private function mailLocale(): string
+ {
+ return $this->user->preferredLocale() ?? 'en';
+ }
}
diff --git a/app/Mail/VerifyAccount.php b/app/Mail/VerifyAccount.php
index 843d782..8ef0767 100644
--- a/app/Mail/VerifyAccount.php
+++ b/app/Mail/VerifyAccount.php
@@ -27,7 +27,7 @@ class VerifyAccount extends Mailable
public function envelope(): Envelope
{
return new Envelope(
- subject: 'Vérifie ton compte Daily Meal',
+ subject: trans('mail.verify_account.subject', [], $this->mailLocale()),
);
}
@@ -40,6 +40,7 @@ class VerifyAccount extends Mailable
view: 'emails.verify-account',
with: [
'expiresInMinutes' => (int) config('auth.verification.expire', 60),
+ 'locale' => $this->mailLocale(),
'userName' => $this->user->name,
'verificationUrl' => $this->verificationUrl,
],
@@ -55,4 +56,9 @@ class VerifyAccount extends Mailable
{
return [];
}
+
+ private function mailLocale(): string
+ {
+ return $this->user->preferredLocale() ?? 'en';
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 2fa8fea..567f2af 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -9,6 +9,7 @@ use App\Enums\WeightGoal;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -18,7 +19,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
-class User extends Authenticatable implements FilamentUser, MustVerifyEmail
+class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUlids, Notifiable;
@@ -31,6 +32,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
protected $fillable = [
'name',
'email',
+ 'locale',
'email_verified_at',
'password',
'avatar_url',
@@ -115,6 +117,11 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
->all();
}
+ public function preferredLocale(): ?string
+ {
+ return $this->locale ?: 'en';
+ }
+
public function isAdmin()
{
return true;
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index 3707b8a..e577b4a 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -26,6 +26,7 @@ class UserFactory extends Factory
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
+ 'locale' => 'en',
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'avatar_url' => null,
diff --git a/database/migrations/2026_05_21_192621_add_locale_to_users_table.php b/database/migrations/2026_05_21_192621_add_locale_to_users_table.php
new file mode 100644
index 0000000..201d76b
--- /dev/null
+++ b/database/migrations/2026_05_21_192621_add_locale_to_users_table.php
@@ -0,0 +1,28 @@
+string('locale', 8)->default('en')->after('email');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('locale');
+ });
+ }
+};
diff --git a/lang/en/admin.php b/lang/en/admin.php
index 39c52a7..e6cde89 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -18,6 +18,7 @@ return [
'id' => 'ID',
'name' => 'Name',
'email' => 'Email address',
+ 'locale' => 'Language',
'email_verified_at' => 'Email verified at',
'account_verified_at' => 'Account verified at',
'avatar' => 'Avatar',
@@ -38,6 +39,10 @@ return [
'created_at' => 'Created at',
'updated_at' => 'Updated at',
],
+ 'locales' => [
+ 'en' => 'English',
+ 'fr' => 'French',
+ ],
'filters' => [
'email_verified' => 'Email verified',
'account_verified' => 'Account verified',
diff --git a/lang/en/mail.php b/lang/en/mail.php
new file mode 100644
index 0000000..f1d76c4
--- /dev/null
+++ b/lang/en/mail.php
@@ -0,0 +1,36 @@
+ [
+ 'subject' => 'Verify your Bowli account',
+ 'title' => 'Verify your account',
+ 'greeting' => 'Hi :name,',
+ 'default_name' => 'there',
+ 'intro' => 'Confirm your email address to activate your account and access Bowli.',
+ 'action' => 'Verify my account',
+ 'expires' => 'This link expires in :minutes minutes.',
+ 'ignore' => 'If you did not create a Bowli account, you can ignore this email.',
+ ],
+ 'reset_password' => [
+ 'subject' => 'Reset your Bowli password',
+ 'title' => 'Reset your password',
+ 'greeting' => 'Hi :name,',
+ 'default_name' => 'there',
+ 'intro' => 'Click the button below to create a new password. The link opens a secure Bowli API page.',
+ 'action' => 'Choose a new password',
+ 'expires' => 'This link expires in :minutes minutes.',
+ 'copy_link' => 'If the button does not work, copy this link into your browser:',
+ 'ignore' => 'If you did not request a password reset, you can ignore this email.',
+ ],
+ 'reset_password_form' => [
+ 'page_title' => 'New password',
+ 'success_title' => 'Password updated',
+ 'title' => 'New password',
+ 'intro' => 'Choose a new password to secure your account.',
+ 'success' => 'Your password has been reset. You can return to the app and sign in.',
+ 'email' => 'Email',
+ 'password' => 'New password',
+ 'password_confirmation' => 'Confirm password',
+ 'submit' => 'Update password',
+ ],
+];
diff --git a/lang/fr/admin.php b/lang/fr/admin.php
index 1df3391..af4d9bc 100644
--- a/lang/fr/admin.php
+++ b/lang/fr/admin.php
@@ -18,6 +18,7 @@ return [
'id' => 'ID',
'name' => 'Nom',
'email' => 'Adresse email',
+ 'locale' => 'Langue',
'email_verified_at' => 'Email vérifié le',
'account_verified_at' => 'Compte vérifié le',
'avatar' => 'Avatar',
@@ -38,6 +39,10 @@ return [
'created_at' => 'Créé le',
'updated_at' => 'Mis à jour le',
],
+ 'locales' => [
+ 'en' => 'Anglais',
+ 'fr' => 'Français',
+ ],
'filters' => [
'email_verified' => 'Email vérifié',
'account_verified' => 'Compte vérifié',
diff --git a/lang/fr/mail.php b/lang/fr/mail.php
new file mode 100644
index 0000000..6ca7e45
--- /dev/null
+++ b/lang/fr/mail.php
@@ -0,0 +1,36 @@
+ [
+ 'subject' => 'Vérifie ton compte Bowli',
+ 'title' => 'Vérifie ton compte',
+ 'greeting' => 'Bonjour :name,',
+ 'default_name' => 'à toi',
+ 'intro' => 'Confirme ton adresse email pour activer ton compte et accéder à Bowli.',
+ 'action' => 'Vérifier mon compte',
+ 'expires' => 'Ce lien expire dans :minutes minutes.',
+ 'ignore' => 'Si tu n’as pas créé de compte Bowli, tu peux ignorer cet email.',
+ ],
+ 'reset_password' => [
+ 'subject' => 'Réinitialise ton mot de passe Bowli',
+ 'title' => 'Réinitialise ton mot de passe',
+ 'greeting' => 'Bonjour :name,',
+ 'default_name' => 'à toi',
+ 'intro' => 'Clique sur le bouton ci-dessous pour créer un nouveau mot de passe. Le lien ouvre une page sécurisée de l’API Bowli.',
+ 'action' => 'Choisir un nouveau mot de passe',
+ 'expires' => 'Ce lien expire dans :minutes minutes.',
+ 'copy_link' => 'Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :',
+ 'ignore' => 'Si tu n’as pas demandé de réinitialisation, tu peux ignorer cet email.',
+ ],
+ 'reset_password_form' => [
+ 'page_title' => 'Nouveau mot de passe',
+ 'success_title' => 'Mot de passe modifié',
+ 'title' => 'Nouveau mot de passe',
+ 'intro' => 'Choisis un nouveau mot de passe pour sécuriser ton compte.',
+ 'success' => 'Ton mot de passe a bien été réinitialisé. Tu peux retourner dans l’application et te connecter.',
+ 'email' => 'Email',
+ 'password' => 'Nouveau mot de passe',
+ 'password_confirmation' => 'Confirmer le mot de passe',
+ 'submit' => 'Modifier le mot de passe',
+ ],
+];
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php
index 8017d5f..dbde43d 100644
--- a/resources/views/auth/reset-password.blade.php
+++ b/resources/views/auth/reset-password.blade.php
@@ -1,9 +1,9 @@
-
+
- Nouveau mot de passe
+ {{ trans('mail.reset_password_form.page_title', [], $locale) }}
@@ -12,16 +12,16 @@
- {{ $passwordReset ? 'Mot de passe modifié' : 'Nouveau mot de passe' }}
+ {{ $passwordReset ? trans('mail.reset_password_form.success_title', [], $locale) : trans('mail.reset_password_form.title', [], $locale) }}
@if ($passwordReset)
- Ton mot de passe a bien été réinitialisé. Tu peux retourner dans l’application et te connecter.
+ {{ trans('mail.reset_password_form.success', [], $locale) }}
@else
- Choisis un nouveau mot de passe pour sécuriser ton compte.
+ {{ trans('mail.reset_password_form.intro', [], $locale) }}