feat: locale
Laravel CI-CD / Tests Unitaires (push) Successful in 39s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m52s Details

This commit is contained in:
Leon Morival 2026-05-21 21:32:20 +02:00
parent 19e65e36f3
commit 8e5a50dd2b
21 changed files with 337 additions and 32 deletions

View File

@ -35,6 +35,11 @@ class UserForm
->required() ->required()
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->maxLength(255), ->maxLength(255),
Select::make('locale')
->label(__('admin.users.fields.locale'))
->options(self::localeOptions())
->default('en')
->required(),
FileUpload::make('avatar_url') FileUpload::make('avatar_url')
->label(__('admin.users.fields.avatar')) ->label(__('admin.users.fields.avatar'))
->image() ->image()
@ -136,4 +141,18 @@ class UserForm
]), ]),
]); ]);
} }
/**
* @return array<string, string>
*/
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();
}
} }

View File

@ -65,6 +65,10 @@ class AuthController extends Controller
], 401); ], 401);
} }
if (isset($data['locale']) && $user->locale !== $data['locale']) {
$user->forceFill(['locale' => $data['locale']])->save();
}
if (! $user->hasVerifiedEmail()) { if (! $user->hasVerifiedEmail()) {
return response()->json([ return response()->json([
'message' => __('api.auth.email_not_verified'), 'message' => __('api.auth.email_not_verified'),
@ -162,9 +166,13 @@ class AuthController extends Controller
public function showResetPasswordForm(Request $request, string $token): View public function showResetPasswordForm(Request $request, string $token): View
{ {
$email = (string) $request->query('email', '');
$locale = $this->localeForEmail($email);
return view('auth.reset-password', [ return view('auth.reset-password', [
'appName' => config('app.name'), 'appName' => config('app.name'),
'email' => (string) $request->query('email', ''), 'email' => $email,
'locale' => $locale,
'passwordReset' => false, 'passwordReset' => false,
'token' => $token, 'token' => $token,
]); ]);
@ -172,6 +180,9 @@ class AuthController extends Controller
public function resetPasswordFromView(ResetPasswordRequest $request): RedirectResponse|View public function resetPasswordFromView(ResetPasswordRequest $request): RedirectResponse|View
{ {
$locale = $this->localeForEmail((string) $request->validated('email'));
app()->setLocale($locale);
$status = $this->resetPasswordFor($request->safe()); $status = $this->resetPasswordFor($request->safe());
if ($status !== Password::PASSWORD_RESET) { if ($status !== Password::PASSWORD_RESET) {
@ -183,6 +194,7 @@ class AuthController extends Controller
return view('auth.reset-password', [ return view('auth.reset-password', [
'appName' => config('app.name'), 'appName' => config('app.name'),
'email' => strtolower($request->validated('email')), 'email' => strtolower($request->validated('email')),
'locale' => $locale,
'passwordReset' => true, 'passwordReset' => true,
'token' => '', '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<string, mixed> $data * @param array<string, mixed> $data
* @return array<string, mixed> * @return array<string, mixed>
@ -307,6 +324,7 @@ class AuthController extends Controller
$attributes = Arr::only($data, [ $attributes = Arr::only($data, [
'name', 'name',
'avatar_url', 'avatar_url',
'locale',
'height', 'height',
'bio', 'bio',
'sex', 'sex',

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoginRequest extends FormRequest class LoginRequest extends FormRequest
{ {
@ -23,7 +24,24 @@ class LoginRequest extends FormRequest
{ {
return [ return [
'email' => ['required', 'string'], 'email' => ['required', 'string'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string'], '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];
}
} }

View File

@ -37,6 +37,7 @@ class RegisterRequest extends FormRequest
'unique:users,name', 'unique:users,name',
], ],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'password' => ['required', 'string', 'min:8'], 'password' => ['required', 'string', 'min:8'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'], 'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'bio' => ['nullable', 'string'], 'bio' => ['nullable', 'string'],
@ -56,4 +57,20 @@ class RegisterRequest extends FormRequest
'avatar.max' => __('api.validation.image_max_2mb'), '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];
}
} }

View File

@ -21,6 +21,7 @@ class UpdateUserRequest extends FormRequest
return [ return [
'bio' => ['sometimes', 'nullable', 'string'], 'bio' => ['sometimes', 'nullable', 'string'],
'height' => ['sometimes', 'nullable', 'integer'], 'height' => ['sometimes', 'nullable', 'integer'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'], 'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'nutritionGoals' => ['sometimes', 'array'], 'nutritionGoals' => ['sometimes', 'array'],
'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'], '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'), '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];
}
} }

View File

@ -18,6 +18,7 @@ class UserResource extends JsonResource
return [ return [
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, 'email' => $this->email,
'locale' => $this->locale,
'emailVerified' => $this->hasVerifiedEmail(), 'emailVerified' => $this->hasVerifiedEmail(),
'emailVerifiedAt' => $this->email_verified_at?->toISOString(), 'emailVerifiedAt' => $this->email_verified_at?->toISOString(),
'height' => $this->height, 'height' => $this->height,

View File

@ -27,7 +27,7 @@ class ResetPasswordMail extends Mailable
public function envelope(): Envelope public function envelope(): Envelope
{ {
return new 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', view: 'emails.reset-password',
with: [ with: [
'expiresInMinutes' => (int) config('auth.passwords.users.expire', 60), 'expiresInMinutes' => (int) config('auth.passwords.users.expire', 60),
'locale' => $this->mailLocale(),
'resetUrl' => $this->resetUrl, 'resetUrl' => $this->resetUrl,
'userName' => $this->user->name, 'userName' => $this->user->name,
], ],
@ -55,4 +56,9 @@ class ResetPasswordMail extends Mailable
{ {
return []; return [];
} }
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
} }

View File

@ -27,7 +27,7 @@ class VerifyAccount extends Mailable
public function envelope(): Envelope public function envelope(): Envelope
{ {
return new 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', view: 'emails.verify-account',
with: [ with: [
'expiresInMinutes' => (int) config('auth.verification.expire', 60), 'expiresInMinutes' => (int) config('auth.verification.expire', 60),
'locale' => $this->mailLocale(),
'userName' => $this->user->name, 'userName' => $this->user->name,
'verificationUrl' => $this->verificationUrl, 'verificationUrl' => $this->verificationUrl,
], ],
@ -55,4 +56,9 @@ class VerifyAccount extends Mailable
{ {
return []; return [];
} }
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
} }

View File

@ -9,6 +9,7 @@ use App\Enums\WeightGoal;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Panel; use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -18,7 +19,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; 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 HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUlids, Notifiable; use HasApiTokens, HasFactory, HasUlids, Notifiable;
@ -31,6 +32,7 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
'locale',
'email_verified_at', 'email_verified_at',
'password', 'password',
'avatar_url', 'avatar_url',
@ -115,6 +117,11 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
->all(); ->all();
} }
public function preferredLocale(): ?string
{
return $this->locale ?: 'en';
}
public function isAdmin() public function isAdmin()
{ {
return true; return true;

View File

@ -26,6 +26,7 @@ class UserFactory extends Factory
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'locale' => 'en',
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'avatar_url' => null, 'avatar_url' => null,

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 8)->default('en')->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

View File

@ -18,6 +18,7 @@ return [
'id' => 'ID', 'id' => 'ID',
'name' => 'Name', 'name' => 'Name',
'email' => 'Email address', 'email' => 'Email address',
'locale' => 'Language',
'email_verified_at' => 'Email verified at', 'email_verified_at' => 'Email verified at',
'account_verified_at' => 'Account verified at', 'account_verified_at' => 'Account verified at',
'avatar' => 'Avatar', 'avatar' => 'Avatar',
@ -38,6 +39,10 @@ return [
'created_at' => 'Created at', 'created_at' => 'Created at',
'updated_at' => 'Updated at', 'updated_at' => 'Updated at',
], ],
'locales' => [
'en' => 'English',
'fr' => 'French',
],
'filters' => [ 'filters' => [
'email_verified' => 'Email verified', 'email_verified' => 'Email verified',
'account_verified' => 'Account verified', 'account_verified' => 'Account verified',

36
lang/en/mail.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'verify_account' => [
'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',
],
];

View File

@ -18,6 +18,7 @@ return [
'id' => 'ID', 'id' => 'ID',
'name' => 'Nom', 'name' => 'Nom',
'email' => 'Adresse email', 'email' => 'Adresse email',
'locale' => 'Langue',
'email_verified_at' => 'Email vérifié le', 'email_verified_at' => 'Email vérifié le',
'account_verified_at' => 'Compte vérifié le', 'account_verified_at' => 'Compte vérifié le',
'avatar' => 'Avatar', 'avatar' => 'Avatar',
@ -38,6 +39,10 @@ return [
'created_at' => 'Créé le', 'created_at' => 'Créé le',
'updated_at' => 'Mis à jour le', 'updated_at' => 'Mis à jour le',
], ],
'locales' => [
'en' => 'Anglais',
'fr' => 'Français',
],
'filters' => [ 'filters' => [
'email_verified' => 'Email vérifié', 'email_verified' => 'Email vérifié',
'account_verified' => 'Compte vérifié', 'account_verified' => 'Compte vérifié',

36
lang/fr/mail.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
'verify_account' => [
'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 nas 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 lAPI 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 nas 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 lapplication et te connecter.',
'email' => 'Email',
'password' => 'Nouveau mot de passe',
'password_confirmation' => 'Confirmer le mot de passe',
'submit' => 'Modifier le mot de passe',
],
];

View File

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="{{ $locale }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nouveau mot de passe</title> <title>{{ trans('mail.reset_password_form.page_title', [], $locale) }}</title>
</head> </head>
<body style="margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;"> <body style="margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<main style="width: min(100% - 32px, 520px); background: #ffffff; border-radius: 8px; padding: 32px; box-sizing: border-box;"> <main style="width: min(100% - 32px, 520px); background: #ffffff; border-radius: 8px; padding: 32px; box-sizing: border-box;">
@ -12,16 +12,16 @@
</p> </p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;"> <h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
{{ $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) }}
</h1> </h1>
@if ($passwordReset) @if ($passwordReset)
<p style="margin: 0; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0; color: #232323; font-size: 16px; line-height: 24px;">
Ton mot de passe a bien été réinitialisé. Tu peux retourner dans lapplication et te connecter. {{ trans('mail.reset_password_form.success', [], $locale) }}
</p> </p>
@else @else
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Choisis un nouveau mot de passe pour sécuriser ton compte. {{ trans('mail.reset_password_form.intro', [], $locale) }}
</p> </p>
@if ($errors->any()) @if ($errors->any())
@ -36,7 +36,7 @@
<input type="hidden" name="token" value="{{ $token }}"> <input type="hidden" name="token" value="{{ $token }}">
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;"> <label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Email {{ trans('mail.reset_password_form.email', [], $locale) }}
<input <input
type="email" type="email"
name="email" name="email"
@ -48,7 +48,7 @@
</label> </label>
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;"> <label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Nouveau mot de passe {{ trans('mail.reset_password_form.password', [], $locale) }}
<input <input
type="password" type="password"
name="password" name="password"
@ -60,7 +60,7 @@
</label> </label>
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;"> <label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
Confirmer le mot de passe {{ trans('mail.reset_password_form.password_confirmation', [], $locale) }}
<input <input
type="password" type="password"
name="password_confirmation" name="password_confirmation"
@ -72,7 +72,7 @@
</label> </label>
<button type="submit" style="min-height: 48px; border: 0; border-radius: 8px; background: #007f3a; color: #ffffff; font-size: 16px; font-weight: 700; cursor: pointer;"> <button type="submit" style="min-height: 48px; border: 0; border-radius: 8px; background: #007f3a; color: #ffffff; font-size: 16px; font-weight: 700; cursor: pointer;">
Modifier le mot de passe {{ trans('mail.reset_password_form.submit', [], $locale) }}
</button> </button>
</form> </form>
@endif @endif

View File

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="{{ $locale }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Réinitialise ton mot de passe Bowli</title> <title>{{ trans('mail.reset_password.subject', [], $locale) }}</title>
</head> </head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;"> <body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
@ -17,33 +17,33 @@
</p> </p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;"> <h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
Réinitialise ton mot de passe {{ trans('mail.reset_password.title', [], $locale) }}
</h1> </h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
Bonjour {{ $userName ?: 'à toi' }}, {{ trans('mail.reset_password.greeting', ['name' => $userName ?: trans('mail.reset_password.default_name', [], $locale)], $locale) }}
</p> </p>
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Clique sur le bouton ci-dessous pour créer un nouveau mot de passe. Le lien ouvre une page sécurisée de lAPI Bowli. {{ trans('mail.reset_password.intro', [], $locale) }}
</p> </p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;"> <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr> <tr>
<td style="background: #007f3a; border-radius: 999px;"> <td style="background: #007f3a; border-radius: 999px;">
<a href="{{ $resetUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;"> <a href="{{ $resetUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Choisir un nouveau mot de passe {{ trans('mail.reset_password.action', [], $locale) }}
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;"> <p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Ce lien expire dans {{ $expiresInMinutes }} minutes. {{ trans('mail.reset_password.expires', ['minutes' => $expiresInMinutes], $locale) }}
</p> </p>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;"> <p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur : {{ trans('mail.reset_password.copy_link', [], $locale) }}
</p> </p>
<p style="margin: 0 0 16px; word-break: break-all; color: #007f3a; font-size: 14px; line-height: 22px;"> <p style="margin: 0 0 16px; word-break: break-all; color: #007f3a; font-size: 14px; line-height: 22px;">
@ -51,7 +51,7 @@
</p> </p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;"> <p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
Si tu nas pas demandé de réinitialisation, tu peux ignorer cet email. {{ trans('mail.reset_password.ignore', [], $locale) }}
</p> </p>
</td> </td>
</tr> </tr>

View File

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="{{ $locale }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vérifie ton compte Daily Meal</title> <title>{{ trans('mail.verify_account.subject', [], $locale) }}</title>
</head> </head>
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;"> <body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
@ -13,37 +13,37 @@
<tr> <tr>
<td style="padding: 32px;"> <td style="padding: 32px;">
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;"> <p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
Daily Meal Bowli
</p> </p>
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;"> <h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
Vérifie ton compte {{ trans('mail.verify_account.title', [], $locale) }}
</h1> </h1>
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
Bonjour {{ $userName ?: 'à toi' }}, {{ trans('mail.verify_account.greeting', ['name' => $userName ?: trans('mail.verify_account.default_name', [], $locale)], $locale) }}
</p> </p>
<p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;"> <p style="margin: 0 0 24px; color: #232323; font-size: 16px; line-height: 24px;">
Confirme ton adresse email pour activer ton compte et accéder à Daily Meal. {{ trans('mail.verify_account.intro', [], $locale) }}
</p> </p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;"> <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
<tr> <tr>
<td style="background: #000000; border-radius: 999px;"> <td style="background: #000000; border-radius: 999px;">
<a href="{{ $verificationUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;"> <a href="{{ $verificationUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Vérifier mon compte {{ trans('mail.verify_account.action', [], $locale) }}
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;"> <p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
Ce lien expire dans {{ $expiresInMinutes }} minutes. {{ trans('mail.verify_account.expires', ['minutes' => $expiresInMinutes], $locale) }}
</p> </p>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;"> <p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
Si tu nas pas créé de compte Daily Meal, tu peux ignorer cet email. {{ trans('mail.verify_account.ignore', [], $locale) }}
</p> </p>
</td> </td>
</tr> </tr>

View File

@ -21,6 +21,7 @@ it('sends a verification email when a user registers', function () {
'name' => 'Leon', 'name' => 'Leon',
'email' => 'leon@example.com', 'email' => 'leon@example.com',
'password' => 'password', 'password' => 'password',
'locale' => 'fr-FR',
'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, 'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value, 'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pacePreference' => PacePreference::NORMAL->value, 'pacePreference' => PacePreference::NORMAL->value,
@ -29,6 +30,7 @@ it('sends a verification email when a user registers', function () {
]) ])
->assertCreated() ->assertCreated()
->assertJsonPath('user.email', 'leon@example.com') ->assertJsonPath('user.email', 'leon@example.com')
->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', false) ->assertJsonPath('user.emailVerified', false)
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value) ->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value)
->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
@ -42,6 +44,7 @@ it('sends a verification email when a user registers', function () {
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'id' => $user->id, 'id' => $user->id,
'locale' => 'fr',
'physical_activity_level' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, 'physical_activity_level' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value, 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pace_preference' => PacePreference::NORMAL->value, 'pace_preference' => PacePreference::NORMAL->value,
@ -54,7 +57,9 @@ it('sends a verification email when a user registers', function () {
}); });
it('uses the custom account verification mailable', function () { it('uses the custom account verification mailable', function () {
$user = User::factory()->unverified()->create(); $user = User::factory()->unverified()->create([
'locale' => 'fr',
]);
$mailable = (new VerifyEmail)->toMail($user); $mailable = (new VerifyEmail)->toMail($user);
@ -65,6 +70,21 @@ it('uses the custom account verification mailable', function () {
->assertSeeInHtml($user->name); ->assertSeeInHtml($user->name);
}); });
it('uses the user locale for account verification emails', function () {
$user = User::factory()->unverified()->create([
'locale' => 'en',
]);
$mailable = (new VerifyEmail)->toMail($user);
expect($mailable)->toBeInstanceOf(VerifyAccount::class);
$mailable
->assertSeeInHtml('Verify my account')
->assertSeeInHtml('Confirm your email address')
->assertDontSeeInHtml('Vérifier mon compte');
});
it('generates verification links with a relative signature', function () { it('generates verification links with a relative signature', function () {
$user = User::factory()->unverified()->create(); $user = User::factory()->unverified()->create();
@ -132,17 +152,22 @@ it('blocks login for unverified users', function () {
it('allows login for verified users', function () { it('allows login for verified users', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'locale' => 'en',
'password' => 'password', 'password' => 'password',
]); ]);
$this->postJson('/api/auth/login', [ $this->postJson('/api/auth/login', [
'email' => $user->email, 'email' => $user->email,
'locale' => 'fr-FR',
'password' => 'password', 'password' => 'password',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('user.email', $user->email) ->assertJsonPath('user.email', $user->email)
->assertJsonPath('user.locale', 'fr')
->assertJsonPath('user.emailVerified', true) ->assertJsonPath('user.emailVerified', true)
->assertCookie('token'); ->assertCookie('token');
expect($user->fresh()->locale)->toBe('fr');
}); });
it('resends a verification email for an unverified user without authentication', function () { it('resends a verification email for an unverified user without authentication', function () {

View File

@ -22,6 +22,7 @@ it('sends a password reset email with a clickable api web url', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'leon@example.com', 'email' => 'leon@example.com',
'locale' => 'fr',
]); ]);
$this $this
@ -58,6 +59,38 @@ it('sends a password reset email with a clickable api web url', function () {
); );
}); });
it('uses the user locale for password reset emails', function () {
Notification::fake();
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'en',
]);
$this
->postJson('/api/auth/forgot-password', [
'email' => $user->email,
])
->assertOk();
Notification::assertSentTo(
$user,
ResetPassword::class,
function (ResetPassword $notification) use ($user): bool {
$mail = $notification->toMail($user);
expect($mail)->toBeInstanceOf(ResetPasswordMail::class);
$mail
->assertSeeInHtml('Reset your password')
->assertSeeInHtml('Choose a new password')
->assertDontSeeInHtml('Réinitialise ton mot de passe');
return true;
}
);
});
it('does not reveal whether a reset email can be sent', function () { it('does not reveal whether a reset email can be sent', function () {
Notification::fake(); Notification::fake();
@ -100,6 +133,7 @@ it('resets the password and invalidates existing api tokens', function () {
it('shows the api password reset form from the email link', function () { it('shows the api password reset form from the email link', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'leon@example.com', 'email' => 'leon@example.com',
'locale' => 'fr',
]); ]);
$token = Password::broker()->createToken($user); $token = Password::broker()->createToken($user);
@ -114,9 +148,29 @@ it('shows the api password reset form from the email link', function () {
->assertSee($user->email); ->assertSee($user->email);
}); });
it('shows the api password reset form in the user locale', function () {
$user = User::factory()->create([
'email' => 'leon@example.com',
'locale' => 'en',
]);
$token = Password::broker()->createToken($user);
$this
->get(route('password.reset', [
'email' => $user->email,
'token' => $token,
], absolute: false))
->assertOk()
->assertViewIs('auth.reset-password')
->assertSee('New password')
->assertSee('Update password')
->assertDontSee('Nouveau mot de passe');
});
it('resets the password from the api web form', function () { it('resets the password from the api web form', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'leon@example.com', 'email' => 'leon@example.com',
'locale' => 'fr',
'password' => 'old-password', 'password' => 'old-password',
]); ]);
$token = Password::broker()->createToken($user); $token = Password::broker()->createToken($user);

View File

@ -22,6 +22,7 @@ it('returns nutrition goals with the authenticated user', function () {
$this->getJson('/api/auth/me') $this->getJson('/api/auth/me')
->assertOk() ->assertOk()
->assertJsonPath('data.locale', 'en')
->assertJsonPath('data.nutritionGoals.calories', 2400) ->assertJsonPath('data.nutritionGoals.calories', 2400)
->assertJsonPath('data.nutritionGoals.proteins', 140) ->assertJsonPath('data.nutritionGoals.proteins', 140)
->assertJsonPath('data.nutritionGoals.carbs', 280) ->assertJsonPath('data.nutritionGoals.carbs', 280)
@ -103,16 +104,19 @@ it('updates profile preferences for the authenticated user', function () {
'pacePreference' => PacePreference::NORMAL->value, 'pacePreference' => PacePreference::NORMAL->value,
'sex' => UserSex::MAN->value, 'sex' => UserSex::MAN->value,
'dateOfBirth' => '2003-04-24', 'dateOfBirth' => '2003-04-24',
'locale' => 'fr-FR',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.physicalActivityLevel', PhysicalActivityLevel::MODERATELY_ACTIVE->value) ->assertJsonPath('data.physicalActivityLevel', PhysicalActivityLevel::MODERATELY_ACTIVE->value)
->assertJsonPath('data.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('data.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
->assertJsonPath('data.pacePreference', PacePreference::NORMAL->value) ->assertJsonPath('data.pacePreference', PacePreference::NORMAL->value)
->assertJsonPath('data.sex', UserSex::MAN->value) ->assertJsonPath('data.sex', UserSex::MAN->value)
->assertJsonPath('data.locale', 'fr')
->assertJsonPath('data.dateOfBirth', '2003-04-24'); ->assertJsonPath('data.dateOfBirth', '2003-04-24');
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'id' => $user->id, 'id' => $user->id,
'locale' => 'fr',
'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE->value, 'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE->value,
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value, 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value,
'pace_preference' => PacePreference::NORMAL->value, 'pace_preference' => PacePreference::NORMAL->value,
@ -130,6 +134,7 @@ it('validates profile preferences', function () {
'weightGoal' => 'bulk', 'weightGoal' => 'bulk',
'pacePreference' => 'urgent', 'pacePreference' => 'urgent',
'sex' => 'x', 'sex' => 'x',
'locale' => 'es',
'dateOfBirth' => 'tomorrow', 'dateOfBirth' => 'tomorrow',
]) ])
->assertUnprocessable() ->assertUnprocessable()
@ -138,6 +143,7 @@ it('validates profile preferences', function () {
'weightGoal', 'weightGoal',
'pacePreference', 'pacePreference',
'sex', 'sex',
'locale',
'dateOfBirth', 'dateOfBirth',
]); ]);
}); });