feat: locale
This commit is contained in:
parent
19e65e36f3
commit
8e5a50dd2b
|
|
@ -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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
|
|
@ -307,6 +324,7 @@ class AuthController extends Controller
|
|||
$attributes = Arr::only($data, [
|
||||
'name',
|
||||
'avatar_url',
|
||||
'locale',
|
||||
'height',
|
||||
'bio',
|
||||
'sex',
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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é',
|
||||
|
|
|
|||
|
|
@ -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 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ $locale }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<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;">
|
||||
|
|
@ -12,16 +12,16 @@
|
|||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
@if ($passwordReset)
|
||||
<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 l’application et te connecter.
|
||||
{{ trans('mail.reset_password_form.success', [], $locale) }}
|
||||
</p>
|
||||
@else
|
||||
<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>
|
||||
|
||||
@if ($errors->any())
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
<input type="hidden" name="token" value="{{ $token }}">
|
||||
|
||||
<label style="display: grid; gap: 8px; color: #232323; font-size: 14px; font-weight: 700;">
|
||||
Email
|
||||
{{ trans('mail.reset_password_form.email', [], $locale) }}
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
</label>
|
||||
|
||||
<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
|
||||
type="password"
|
||||
name="password"
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
</label>
|
||||
|
||||
<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
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
</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;">
|
||||
Modifier le mot de passe
|
||||
{{ trans('mail.reset_password_form.submit', [], $locale) }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ $locale }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<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;">
|
||||
|
|
@ -17,33 +17,33 @@
|
|||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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 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 l’API Bowli.
|
||||
{{ trans('mail.reset_password.intro', [], $locale) }}
|
||||
</p>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
|
||||
<tr>
|
||||
<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;">
|
||||
Choisir un nouveau mot de passe
|
||||
{{ trans('mail.reset_password.action', [], $locale) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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 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 style="margin: 0 0 16px; word-break: break-all; color: #007f3a; font-size: 14px; line-height: 22px;">
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
</p>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
|
||||
Si tu n’as pas demandé de réinitialisation, tu peux ignorer cet email.
|
||||
{{ trans('mail.reset_password.ignore', [], $locale) }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="{{ $locale }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<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;">
|
||||
|
|
@ -13,37 +13,37 @@
|
|||
<tr>
|
||||
<td style="padding: 32px;">
|
||||
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
|
||||
Daily Meal
|
||||
Bowli
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
|
||||
<tr>
|
||||
<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;">
|
||||
Vérifier mon compte
|
||||
{{ trans('mail.verify_account.action', [], $locale) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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 style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
|
||||
Si tu n’as pas créé de compte Daily Meal, tu peux ignorer cet email.
|
||||
{{ trans('mail.verify_account.ignore', [], $locale) }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ it('sends a verification email when a user registers', function () {
|
|||
'name' => 'Leon',
|
||||
'email' => 'leon@example.com',
|
||||
'password' => 'password',
|
||||
'locale' => 'fr-FR',
|
||||
'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
|
||||
'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value,
|
||||
'pacePreference' => PacePreference::NORMAL->value,
|
||||
|
|
@ -29,6 +30,7 @@ it('sends a verification email when a user registers', function () {
|
|||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('user.email', 'leon@example.com')
|
||||
->assertJsonPath('user.locale', 'fr')
|
||||
->assertJsonPath('user.emailVerified', false)
|
||||
->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->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', [
|
||||
'id' => $user->id,
|
||||
'locale' => 'fr',
|
||||
'physical_activity_level' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value,
|
||||
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->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 () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
$user = User::factory()->unverified()->create([
|
||||
'locale' => 'fr',
|
||||
]);
|
||||
|
||||
$mailable = (new VerifyEmail)->toMail($user);
|
||||
|
||||
|
|
@ -65,6 +70,21 @@ it('uses the custom account verification mailable', function () {
|
|||
->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 () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
|
|
@ -132,17 +152,22 @@ it('blocks login for unverified users', function () {
|
|||
|
||||
it('allows login for verified users', function () {
|
||||
$user = User::factory()->create([
|
||||
'locale' => 'en',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/login', [
|
||||
'email' => $user->email,
|
||||
'locale' => 'fr-FR',
|
||||
'password' => 'password',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('user.email', $user->email)
|
||||
->assertJsonPath('user.locale', 'fr')
|
||||
->assertJsonPath('user.emailVerified', true)
|
||||
->assertCookie('token');
|
||||
|
||||
expect($user->fresh()->locale)->toBe('fr');
|
||||
});
|
||||
|
||||
it('resends a verification email for an unverified user without authentication', function () {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ it('sends a password reset email with a clickable api web url', function () {
|
|||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'leon@example.com',
|
||||
'locale' => 'fr',
|
||||
]);
|
||||
|
||||
$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 () {
|
||||
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 () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'leon@example.com',
|
||||
'locale' => 'fr',
|
||||
]);
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
|
|
@ -114,9 +148,29 @@ it('shows the api password reset form from the email link', function () {
|
|||
->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 () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'leon@example.com',
|
||||
'locale' => 'fr',
|
||||
'password' => 'old-password',
|
||||
]);
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ it('returns nutrition goals with the authenticated user', function () {
|
|||
|
||||
$this->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.locale', 'en')
|
||||
->assertJsonPath('data.nutritionGoals.calories', 2400)
|
||||
->assertJsonPath('data.nutritionGoals.proteins', 140)
|
||||
->assertJsonPath('data.nutritionGoals.carbs', 280)
|
||||
|
|
@ -103,16 +104,19 @@ it('updates profile preferences for the authenticated user', function () {
|
|||
'pacePreference' => PacePreference::NORMAL->value,
|
||||
'sex' => UserSex::MAN->value,
|
||||
'dateOfBirth' => '2003-04-24',
|
||||
'locale' => 'fr-FR',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.physicalActivityLevel', PhysicalActivityLevel::MODERATELY_ACTIVE->value)
|
||||
->assertJsonPath('data.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value)
|
||||
->assertJsonPath('data.pacePreference', PacePreference::NORMAL->value)
|
||||
->assertJsonPath('data.sex', UserSex::MAN->value)
|
||||
->assertJsonPath('data.locale', 'fr')
|
||||
->assertJsonPath('data.dateOfBirth', '2003-04-24');
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'locale' => 'fr',
|
||||
'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE->value,
|
||||
'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value,
|
||||
'pace_preference' => PacePreference::NORMAL->value,
|
||||
|
|
@ -130,6 +134,7 @@ it('validates profile preferences', function () {
|
|||
'weightGoal' => 'bulk',
|
||||
'pacePreference' => 'urgent',
|
||||
'sex' => 'x',
|
||||
'locale' => 'es',
|
||||
'dateOfBirth' => 'tomorrow',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
|
|
@ -138,6 +143,7 @@ it('validates profile preferences', function () {
|
|||
'weightGoal',
|
||||
'pacePreference',
|
||||
'sex',
|
||||
'locale',
|
||||
'dateOfBirth',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue