Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
},
"ignorePatterns": ["**/node_modules/**", "**/mgr/**"],
"rules": {
"no-unused-vars": ["error", { "varsIgnorePattern": "^(CartUI|CustomerUI|OrderUI|ProductCardUI|QuantityUI)$" }]
"no-unused-vars": ["error", { "varsIgnorePattern": "^(CartUI|CustomerAPI|CustomerUI|OrderUI|ProductCardUI|QuantityUI)$" }]
}
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
**ServiceRegistry — лишний debug-шум в логах (#225, closes #224):**
- На штатной установке без кастомизации сервисов `loadMainConfig()` / `loadAddonConfigs()` писали DEBUG про отсутствие дефолтных override-путей. Теперь логирование срабатывает только если оператор явно задал `ms3_services_config` / `ms3_services_addons_dir` через system settings, а файла/папки по этому пути нет.

**Подтверждение email на витрине — ссылка из письма и повторная отправка в ЛК (#226):**
- Ссылка по умолчанию ведёт на Web API `api.php?route=…/email/verify&token=…&html=1` (раньше — несуществующий `verify-email` на `site_url`); кастомный URL — системная настройка `ms3_email_verification_url`. После клика в письме: редирект на сайт с `ms3_email_verified=1|0` (для success — опционально `ms3_email_verification_success_url`); `format=json` по-прежнему отдаёт JSON.
- `Response` и `api.php` поддерживают HTTP-редирект вместо JSON для таких маршрутов.
- Подключены `CustomerAPI::resendVerificationEmail`, `CustomerUI`, селекторы и `ms3_customer_profile.tpl` — кнопка resend инициирует `POST /api/v1/customer/email/resend-verification`. Добавлены README, лексиконы, smoke-тест `core/components/minishop3/tests/EmailVerificationUrlTest.php`.

#### 📁 Изменённые файлы

```
Expand Down
10 changes: 10 additions & 0 deletions _build/elements/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,16 @@
'xtype' => 'numberfield',
'area' => 'ms3_security',
],
'ms3_email_verification_url' => [
'value' => '',
'xtype' => 'textfield',
'area' => 'ms3_security',
],
'ms3_email_verification_success_url' => [
'value' => '',
'xtype' => 'textfield',
'area' => 'ms3_security',
],
'ms3_payment_secret' => [
'value' => '',
'xtype' => 'textfield',
Expand Down
16 changes: 10 additions & 6 deletions assets/components/minishop3/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
* @package MiniShop3
*/

// Устанавливаем заголовки для JSON API
header('Content-Type: application/json; charset=utf-8');
// JSON Content-Type выставляется только при отдаче тела (не при HTTP redirect)

// Проверяем наличие параметра route
if (empty($_REQUEST['route'])) {
Expand Down Expand Up @@ -92,14 +91,19 @@
// Обрабатываем запрос
$response = $router->dispatch($route, $_SERVER['REQUEST_METHOD']);

// Получаем данные ответа
$responseData = $response->getData();
$statusCode = $response->getStatusCode();
$redirectUrl = $response->getRedirectUrl();
if ($redirectUrl !== null && $redirectUrl !== '') {
http_response_code($statusCode);
header('Location: ' . $redirectUrl);
exit;
}

$responseData = $response->getData();

// Устанавливаем HTTP статус код
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');

// Выводим JSON
echo json_encode($responseData, JSON_UNESCAPED_UNICODE);

} catch (\Exception $e) {
Expand Down
11 changes: 11 additions & 0 deletions assets/components/minishop3/js/web/core/CustomerAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,15 @@ class CustomerAPI {
async cancelOrder (orderId) {
return this.api.post(`/api/v1/customer/orders/${orderId}/cancel`)
}

/**
* Resend email verification (cabinet; requires customer session)
*
* POST /api/v1/customer/email/resend-verification
*
* @returns {Promise<Object>}
*/
async resendVerificationEmail () {
return this.api.post('/api/v1/customer/email/resend-verification', {})
}
}
2 changes: 2 additions & 0 deletions assets/components/minishop3/js/web/core/Selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const defaultSelectors = {
orderCancel: '.ms3-order-cancel',
addressSetDefault: '.set-default-address',
addressDelete: '.delete-address',
resendVerificationEmail:
'#resend-verification-email, [data-ms3-resend-verification]',
authLoginForm: '#ms3-login-form',
authRegisterForm: '#ms3-register-form',
authForgotPassword: '#forgot-password-link'
Expand Down
2 changes: 2 additions & 0 deletions assets/components/minishop3/js/web/ms3.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ const ms3 = {
orderCancel: '.ms3-order-cancel',
addressSetDefault: '.set-default-address',
addressDelete: '.delete-address',
resendVerificationEmail:
'#resend-verification-email, [data-ms3-resend-verification]',
authLoginForm: '#ms3-login-form',
authRegisterForm: '#ms3-register-form',
authForgotPassword: '#forgot-password-link'
Expand Down
47 changes: 46 additions & 1 deletion assets/components/minishop3/js/web/ui/CustomerUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const CUSTOMER_UI_LEXICON = {
ms3_customer_order_cancel_error: 'Failed to cancel order',
ms3_customer_order_cancel_request_error: 'Request failed',
ms3_customer_address_set_default_error: 'Failed to set default address',
ms3_customer_address_delete_error: 'Failed to delete address'
ms3_customer_address_delete_error: 'Failed to delete address',
ms3_email_verification_sent: 'Verification email has been sent'
}

class CustomerUI {
Expand Down Expand Up @@ -64,6 +65,7 @@ class CustomerUI {
})
this.initOrderCancel()
this.initAddressManagement()
this.initResendVerification()
}

/**
Expand Down Expand Up @@ -324,6 +326,49 @@ class CustomerUI {
}
}

/**
* Resend email verification (profile / cabinet)
*/
initResendVerification () {
const selector = this.selectors.resendVerificationEmail
if (!selector) {
return
}
document.querySelectorAll(selector).forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault()
if (btn.disabled) {
return
}
const hookData = { button: btn }
await this.hooks.runHooks('beforeResendVerificationEmail', hookData)
if (hookData.cancel) {
return
}
btn.disabled = true
try {
const response = await this.customer.resendVerificationEmail()
await this.hooks.runHooks('afterResendVerificationEmail', { response, button: btn })
if (response.success) {
this.message.success(
response.message || this.t('ms3_email_verification_sent')
)
setTimeout(() => {
window.location.reload()
}, 1200)
} else {
this.message.error(response.message || this.t('ms3_customer_err_occurred'))
btn.disabled = false
}
} catch (error) {
console.error('CustomerUI.initResendVerification error:', error)
this.message.error(this.t('ms3_customer_err_occurred'))
btn.disabled = false
}
})
})
}

/**
* Initialize order cancel buttons
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<button class="btn btn-outline-warning"
type="button"
id="resend-verification-email"
data-ms3-resend-verification
data-customer-id="{$customer.id}">
{'ms3_customer_email_send_verification' | lexicon}
</button>
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@
$_lang['setting_ms3_password_reset_token_ttl_desc'] = 'Time in seconds for which the password reset link remains valid. Default is 3600 (1 hour).';
$_lang['setting_ms3_email_verification_token_ttl'] = 'Email verification token Time-To-Live (TTL)';
$_lang['setting_ms3_email_verification_token_ttl_desc'] = 'Time in seconds for which the email verification link remains valid. Default is 86400 (24 hours).';
$_lang['setting_ms3_email_verification_url'] = 'Custom email verification link (optional)';
$_lang['setting_ms3_email_verification_url_desc'] = 'If empty, the link points to the Web API (api.php) verify route. To use your own page, set a full URL and include the placeholder [[+token]] or {token} where the token must appear.';
$_lang['setting_ms3_email_verification_success_url'] = 'Redirect URL after successful email verification (optional)';
$_lang['setting_ms3_email_verification_success_url_desc'] = 'Used when the user opens the verification link from email (html=1). If empty, site_url is used; the query parameter ms3_email_verified=1 is appended.';
$_lang['setting_ms3_payment_secret'] = 'Payment secret key';
$_lang['setting_ms3_payment_secret_desc'] = 'Secret key for generating payment notification signatures. Recommended to set a unique value for improved security.';

Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@
$_lang['setting_ms3_password_reset_token_ttl_desc'] = 'Время в секундах, в течение которого ссылка для сброса пароля остается действительной. По умолчанию 3600 (1 час).';
$_lang['setting_ms3_email_verification_token_ttl'] = 'Время жизни токена верификации email (TTL)';
$_lang['setting_ms3_email_verification_token_ttl_desc'] = 'Время в секундах, в течение которого ссылка для верификации email остается действительной. По умолчанию 86400 (24 часа).';
$_lang['setting_ms3_email_verification_url'] = 'Свой URL подтверждения email (необязательно)';
$_lang['setting_ms3_email_verification_url_desc'] = 'Если пусто, в письме подставляется ссылка на Web API (api.php), маршрут верификации. Для своей страницы укажите полный URL и плейсхолдер [[+token]] или {token} для подстановки токена.';
$_lang['setting_ms3_email_verification_success_url'] = 'URL редиректа после успешной верификации email (необязательно)';
$_lang['setting_ms3_email_verification_success_url_desc'] = 'Используется при переходе по ссылке из письма (параметр html=1). Если пусто — берётся site_url; к URL добавляется параметр ms3_email_verified=1.';
$_lang['setting_ms3_payment_secret'] = 'Секретный ключ для платежей';
$_lang['setting_ms3_payment_secret_desc'] = 'Секретный ключ для генерации подписей платежных уведомлений. Рекомендуется установить уникальное значение для повышения безопасности.';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use MiniShop3\MiniShop3;
use MiniShop3\Model\msCustomer;
use MiniShop3\Router\Response;
use MiniShop3\Services\Customer\EmailVerificationService;
use MODX\Revolution\modX;

Expand Down Expand Up @@ -81,20 +82,34 @@ public function resendVerification(): array
*
* GET /api/v1/customer/email/verify?token={token}
*
* - `format=json` — всегда JSON (интеграции, отладка).
* - `html=1` (как в ссылке из письма) — после успеха/ошибки HTTP 302 на сайт (см. GH-226).
*
* @param array $params Request parameters
* @return array ['success' => bool, 'message' => string]
* @return array|Response
*/
public function verify(array $params): array
public function verify(array $params): array|Response
{
$formatJson = ($params['format'] ?? '') === 'json';
$htmlFlow = ($params['html'] ?? '') === '1';

$token = $params['token'] ?? '';

if (empty($token)) {
if ($htmlFlow && !$formatJson) {
return Response::redirect($this->buildEmailVerificationFailedRedirectUrl(), 302);
}

return $this->error($this->modx->lexicon('ms3_customer_err_token_required'));
}

$customer = $this->emailVerification->verifyToken($token);

if (!$customer) {
if ($htmlFlow && !$formatJson) {
return Response::redirect($this->buildEmailVerificationFailedRedirectUrl(), 302);
}

return $this->error($this->modx->lexicon('ms3_customer_err_email_verification_invalid'));
}

Expand All @@ -106,12 +121,40 @@ public function verify(array $params): array
"[CustomerEmailController] Email verified and customer #{$customer->id} auto-logged in"
);

if ($htmlFlow && !$formatJson) {
return Response::redirect($this->buildEmailVerificationSuccessRedirectUrl(), 302);
}

return $this->success(
$this->modx->lexicon('ms3_customer_email_verified'),
['customer_id' => $customer->id]
);
}

/**
* Куда вести пользователя после успешной верификации (браузер, html=1)
*/
protected function buildEmailVerificationSuccessRedirectUrl(): string
{
$target = trim((string) $this->modx->getOption('ms3_email_verification_success_url', null, ''));
if ($target === '') {
$target = rtrim((string) $this->modx->getOption('site_url', null, '/'), '/');
}
$sep = str_contains($target, '?') ? '&' : '?';

return $target . $sep . 'ms3_email_verified=1';
}

/**
* Куда вести при невалидном/просроченном токене (браузер, html=1)
*/
protected function buildEmailVerificationFailedRedirectUrl(): string
{
$base = rtrim((string) $this->modx->getOption('site_url', null, '/'), '/');

return $base . '?ms3_email_verified=0';
}

/**
* Success response
*
Expand Down
27 changes: 27 additions & 0 deletions core/components/minishop3/src/Router/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,32 @@ class Response
protected $statusCode;
protected $headers = [];

/** @var string|null HTTP redirect target (Location) */
protected ?string $redirectUrl = null;

public function __construct($data, int $statusCode = 200, array $headers = [])
{
$this->data = $data;
$this->statusCode = $statusCode;
$this->headers = $headers;
}

/**
* Redirect response (e.g. email verification in browser; api.php sends Location)
*/
public static function redirect(string $url, int $statusCode = 302): self
{
$r = new self(null, $statusCode);
$r->redirectUrl = $url;

return $r;
}

public function getRedirectUrl(): ?string
{
return $this->redirectUrl;
}

/**
* Create success response
*/
Expand Down Expand Up @@ -59,6 +78,14 @@ public function send(): void
{
http_response_code($this->statusCode);

if ($this->redirectUrl !== null) {
header('Location: ' . $this->redirectUrl);
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
}
exit;
}

header('Content-Type: application/json; charset=utf-8');
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
Expand Down
Loading