generated from boboko/starter
account pages: login, order history, order details, account details, wishlist
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Privacy\Services\PrivacyService;
|
||||
|
||||
/**
|
||||
* The profile page: name, invoice details, the one address, and account
|
||||
* deletion. The customer keeps a single address, flagged as both the
|
||||
* shipping and billing default, so allowing several later is a UI change
|
||||
* only (the data is already Lunar's own addresses table).
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$customer = $this->account->customer($user);
|
||||
|
||||
return view('account.show', [
|
||||
'user' => $user,
|
||||
'customer' => $customer,
|
||||
'address' => $this->defaultAddress($user),
|
||||
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$country = $this->storeCountry();
|
||||
|
||||
// The address is all-or-nothing: typing any part of it makes the rest
|
||||
// (and the name, which Lunar requires on every address) required.
|
||||
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||
|
||||
$data = $request->validate([
|
||||
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'invoice' => ['boolean'],
|
||||
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||
'state' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'required_with:'.$anyAddressField,
|
||||
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||
],
|
||||
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||
]);
|
||||
|
||||
$invoice = $request->boolean('invoice');
|
||||
|
||||
$customer = $this->account->updateProfile($user, [
|
||||
'first_name' => $data['first_name'] ?? null,
|
||||
'last_name' => $data['last_name'] ?? null,
|
||||
'company_name' => $invoice ? $data['company_name'] : 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(),
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'country_id' => $country->id,
|
||||
'contact_email' => $user->email,
|
||||
'shipping_default' => true,
|
||||
'billing_default' => true,
|
||||
];
|
||||
|
||||
$existing = $this->defaultAddress($user);
|
||||
|
||||
$existing
|
||||
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||
: $this->account->createAddress($user, $addressData);
|
||||
}
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||
* (which blocks the login right away) and logs out. Logging back in within
|
||||
* the grace period cancels it; see core's docs/privacy.md.
|
||||
*/
|
||||
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$privacy->requestErasureForUser($user, $user);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||
}
|
||||
|
||||
private function defaultAddress($user)
|
||||
{
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
|
||||
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
}
|
||||
|
||||
private function storeCountry(): Country
|
||||
{
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\EmailChangeCodeMail;
|
||||
use App\Mail\EmailChangedNoticeMail;
|
||||
use App\Services\GuestOrderClaimer;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
public function edit(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.email', ['user' => $request->user()]);
|
||||
}
|
||||
|
||||
public function send(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'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'])) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
return redirect()->route('account.email.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
return view('account.email-code', ['email' => $pending['email']]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
if (! $this->sendCode($request, $pending['email'])) {
|
||||
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 $orders): RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
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()) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
}
|
||||
|
||||
$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.
|
||||
$orders->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||
* ever returns the logged-in user's own placed orders, so an order id from
|
||||
* the URL can't reach someone else's order.
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function index(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.orders.index', [
|
||||
'orders' => $this->account->orders($request->user(), 15),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $locale, Request $request, int $orderId): View
|
||||
{
|
||||
try {
|
||||
$order = $this->account->order($request->user(), $orderId);
|
||||
} catch (OrderNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// By type, like the checkout confirmation: the method may since have
|
||||
// been disabled, but the order still shows what was used.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('account.orders.show', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user