3 Commits
7 changed files with 52 additions and 193 deletions
@@ -75,16 +75,13 @@ public function update(string $locale, Request $request): RedirectResponse
$invoice = $request->boolean('invoice');
$customer = $this->account->updateProfile($user, [
$this->account->updateProfile($user, [
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'company_name' => $invoice ? $data['company_name'] : null,
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
]);
// Written directly: core's updateProfile() allowlists `vat_no`, but
// Lunar's column is `tax_identifier`, so it can't go through there yet.
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
if (filled($data['line_one'] ?? null)) {
$addressData = [
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
@@ -103,28 +100,20 @@ public function update(string $locale, Request $request): RedirectResponse
: $this->account->createAddress($user, $addressData);
}
$this->updateRecoveryConsent($customer, $request->boolean('recovery_consent'));
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
return redirect()->route('account')->with('status', __('storefront.account.saved'));
}
/**
* "Email me a reminder if I don't finish my order", as a standing choice.
* Stored on the customer in the same meta shape the checkout writes (see
* CheckoutController::rememberRecoveryConsent()), and applied to the
* "Email me a reminder if I don't finish my order", as a standing
* choice — stored on the customer via boboko-core's
* CustomerAccountService::setRecoveryConsent(), and applied to the
* current cart too, so opting out stops reminders for it right away.
*/
private function updateRecoveryConsent($customer, bool $consent): void
private function updateRecoveryConsent($user, bool $consent): void
{
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
$this->account->setRecoveryConsent($user, $consent);
// Only an existing cart; never create one just to record this.
$cart = app(CartService::class)->current();
@@ -3,43 +3,36 @@
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Mail\EmailChangeCodeMail;
use App\Mail\EmailChangedNoticeMail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Core\Customer\Services\GuestOrderClaimer;
use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
use Modules\Core\Customer\Services\CustomerEmailChangeService;
/**
* Changing the login email: new email → 6-digit code sent to that address →
* switched. The email is the login, so it only changes once the shopper has
* proved they can receive mail there; a typo can never lock them out.
*
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
*
* Core has no email-change flow, so the pending change lives in the session
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
* Changing the login email — a thin wrapper over boboko-core's
* Customer\Services\CustomerEmailChangeService, which owns the actual
* request/confirm mechanics, throttling, pending-change storage, and
* mailables. This controller's own job is just the storefront's session-
* scoped "which email did I just ask to switch to" UI state (so the
* .code/.resend pages know which address to show/resend to) and
* translating the service's exceptions into the flash-message flow the
* views expect.
*/
class EmailController extends Controller
{
private const SESSION_KEY = 'email_change';
private const EXPIRY_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
private const SESSION_KEY = 'pending_email_change';
public function edit(string $locale, Request $request): View
{
return view('account.email', ['user' => $request->user()]);
}
public function send(string $locale, Request $request): RedirectResponse
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$user = $request->user();
@@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse
'email',
'max:255',
Rule::notIn([$user->email]),
Rule::unique($user->getTable(), 'email')->ignore($user->id),
],
], [
'email.not_in' => __('storefront.account.email_same'),
'email.unique' => __('storefront.account.email_taken'),
]);
if (! $this->sendCode($request, $validated['email'])) {
try {
$emailChange->request($user, $validated['email']);
} catch (EmailAlreadyTakenException) {
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (OtpThrottledException) {
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
}
$request->session()->put(self::SESSION_KEY, $validated['email']);
return redirect()->route('account.email.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
return view('account.email-code', ['email' => $pending['email']]);
return view('account.email-code', ['email' => $pendingEmail]);
}
public function resend(string $locale, Request $request): RedirectResponse
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
if (! $this->sendCode($request, $pending['email'])) {
try {
$emailChange->request($request->user(), $pendingEmail);
} catch (EmailAlreadyTakenException) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (OtpThrottledException) {
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
}
return back()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, GuestOrderClaimer $claimer): RedirectResponse
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$pending = $request->session()->get(self::SESSION_KEY);
$pendingEmail = $request->session()->get(self::SESSION_KEY);
if (! $pending) {
if (! $pendingEmail) {
return redirect()->route('account.email.edit');
}
$validated = $request->validate(['code' => ['required', 'digits:6']]);
$valid = $pending['code_hash'] !== null
&& now()->timestamp < $pending['expires_at']
&& Hash::check($validated['code'], $pending['code_hash']);
if (! $valid) {
// Too many wrong guesses burns the code; only "resend" helps then.
$pending['attempts']++;
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
$pending['code_hash'] = null;
}
$request->session()->put(self::SESSION_KEY, $pending);
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$user = $request->user();
// Someone may have signed up with this address since the code was sent.
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
try {
$emailChange->confirm($request->user(), $validated['code']);
} catch (EmailAlreadyTakenException) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (InvalidEmailChangeCodeException) {
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$oldEmail = $user->email;
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
// Lets the owner notice if someone else changed it from a hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
// The code just proved they own the new address too.
$claimer->claim($user);
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
}
private function sendCode(Request $request, string $email): bool
{
$limiterKey = 'email-change:'.$request->user()->id;
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
return false;
}
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put(self::SESSION_KEY, [
'email' => $email,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
]);
Mail::to($email)->send(new EmailChangeCodeMail($code));
return true;
}
}
@@ -242,7 +242,7 @@ public function saveAddress(string $locale, Request $request): JsonResponse
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
if (Auth::check()) {
$this->rememberRecoveryConsent($request->boolean('recovery_consent'));
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
}
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
@@ -665,29 +665,6 @@ private function prefillFromAccount(Cart $cart): Cart
return $cart;
}
/**
* The shopper's latest reminder choice, kept on their customer record
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
* cart) so their next checkout starts from it. The storefront's account
* page reads/writes the same keys. Candidate for a boboko-core method.
*/
private function rememberRecoveryConsent(bool $consent): void
{
$customer = $this->account->customer(Auth::user());
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
return;
}
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
private function storeCountry(): ?Country
{
if (self::STORE_COUNTRY_ISO3 === null) {
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* The code that confirms a new login email — see Account\EmailController.
*/
class EmailChangeCodeMail extends Mailable
{
public function __construct(
public readonly string $code,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
}
public function content(): Content
{
return new Content(view: 'emails.email-change-code');
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Sent to the OLD address once the login email has changed (see
* Account\EmailController), so the owner notices a takeover. The new address
* is shown masked, e.g. "n•••@example.com".
*/
class EmailChangedNoticeMail extends Mailable
{
public readonly string $maskedEmail;
public function __construct(string $newEmail)
{
[$local, $domain] = explode('@', $newEmail, 2);
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε');
}
public function content(): Content
{
return new Content(view: 'emails.email-changed-notice');
}
}