From f52360449b5888026e45b8cecf34d2a10ebb0dad Mon Sep 17 00:00:00 2001 From: Boris Kovalskii <36034823+JustYay@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:27:29 +1000 Subject: [PATCH] feat(admin): overpay certificate upload block (cherry picked from commit b51b9d7d45db7b4597838d25a475291e453c7a7a) --- src/api/adminOverpayCertificate.ts | 48 +++++++ src/locales/en.json | 16 +++ src/locales/fa.json | 18 ++- src/locales/ru.json | 16 +++ src/locales/zh.json | 18 ++- src/pages/AdminPaymentMethodEdit.tsx | 192 ++++++++++++++++++++++++++- 6 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 src/api/adminOverpayCertificate.ts diff --git a/src/api/adminOverpayCertificate.ts b/src/api/adminOverpayCertificate.ts new file mode 100644 index 0000000..dea24c9 --- /dev/null +++ b/src/api/adminOverpayCertificate.ts @@ -0,0 +1,48 @@ +import apiClient from './client'; + +export const OVERPAY_CERT_MAX_SIZE = 1024 * 1024; + +export interface OverpayCertificateStatus { + uploaded: boolean; + valid: boolean; + path: string; + subject: string | null; + not_valid_after: string | null; + has_chain: boolean | null; + env_locked_path: boolean; + env_locked_passphrase: boolean; +} + +export interface OverpayCertificateUploadResponse { + subject: string; + not_valid_after: string; + has_chain: boolean; + path: string; + env_locked_path: boolean; + env_locked_passphrase: boolean; + warning: string | null; +} + +export const adminOverpayCertificateApi = { + getStatus: async (): Promise => { + const response = await apiClient.get( + '/cabinet/admin/overpay/certificate', + ); + return response.data; + }, + + upload: async (file: File, passphrase: string): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('passphrase', passphrase); + const response = await apiClient.post( + '/cabinet/admin/overpay/certificate', + formData, + ); + return response.data; + }, + + remove: async (): Promise => { + await apiClient.delete('/cabinet/admin/overpay/certificate'); + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 1fcd7da..420ae1c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1636,6 +1636,22 @@ "quickAmountsInvalid": "Enter a positive amount in rubles", "quickAmountsLimit": "No more than 10 amounts", "quickAmountsRemove": "Remove amount {{value}} ₽", + "overpayCertTitle": "mTLS certificate (Overpay)", + "overpayCertValid": "✅ Valid until {{date}}", + "overpayCertUnreadable": "⚠️ Certificate cannot be read — wrong passphrase or corrupted file", + "overpayCertMissing": "❌ Certificate not uploaded", + "overpayCertEnvLockedPath": "Path is set via the OVERPAY_P12_PATH environment variable — the database value is not applied", + "overpayCertEnvLockedPassphrase": "Passphrase is set via the OVERPAY_P12_PASSPHRASE environment variable — the database value is not applied", + "overpayCertFile": "Certificate file (.p12 / .pfx)", + "overpayCertChooseFile": "Choose file", + "overpayCertPassphrase": "Certificate passphrase", + "overpayCertPassphrasePlaceholder": "Leave empty if none", + "overpayCertUpload": "Upload", + "overpayCertDelete": "Delete", + "overpayCertConfirmDelete": "Delete the Overpay certificate?", + "overpayCertFileTooLarge": "File exceeds 1 MB", + "overpayCertUploadError": "Failed to upload certificate", + "overpayCertDeleteError": "Failed to delete certificate", "conditions": "Display conditions", "userTypeFilter": "User type", "userTypeAll": "All", diff --git a/src/locales/fa.json b/src/locales/fa.json index f064245..d5382d4 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2312,7 +2312,23 @@ "quickAmountsAdd": "افزودن", "quickAmountsInvalid": "یک مبلغ مثبت به روبل وارد کنید", "quickAmountsLimit": "حداکثر ۱۰ مبلغ مجاز است", - "quickAmountsRemove": "حذف مبلغ {{value}} ₽" + "quickAmountsRemove": "حذف مبلغ {{value}} ₽", + "overpayCertTitle": "گواهی mTLS (Overpay)", + "overpayCertValid": "✅ معتبر تا {{date}}", + "overpayCertUnreadable": "⚠️ گواهی قابل خواندن نیست — رمز نادرست یا فایل خراب است", + "overpayCertMissing": "❌ گواهی بارگذاری نشده است", + "overpayCertEnvLockedPath": "مسیر از طریق متغیر محیطی OVERPAY_P12_PATH تنظیم شده است — مقدار پایگاه داده اعمال نمی‌شود", + "overpayCertEnvLockedPassphrase": "رمز از طریق متغیر محیطی OVERPAY_P12_PASSPHRASE تنظیم شده است — مقدار پایگاه داده اعمال نمی‌شود", + "overpayCertFile": "فایل گواهی (.p12 / .pfx)", + "overpayCertChooseFile": "انتخاب فایل", + "overpayCertPassphrase": "رمز گواهی", + "overpayCertPassphrasePlaceholder": "اگر رمزی ندارد خالی بگذارید", + "overpayCertUpload": "بارگذاری", + "overpayCertDelete": "حذف", + "overpayCertConfirmDelete": "گواهی Overpay حذف شود؟", + "overpayCertFileTooLarge": "حجم فایل بیش از ۱ مگابایت است", + "overpayCertUploadError": "بارگذاری گواهی ناموفق بود", + "overpayCertDeleteError": "حذف گواهی ناموفق بود" }, "campaigns": { "title": "کمپین‌های تبلیغاتی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2078234..fac3440 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1658,6 +1658,22 @@ "quickAmountsInvalid": "Введите положительную сумму в рублях", "quickAmountsLimit": "Не более 10 сумм", "quickAmountsRemove": "Удалить сумму {{value}} ₽", + "overpayCertTitle": "Сертификат mTLS (Overpay)", + "overpayCertValid": "✅ Действует до {{date}}", + "overpayCertUnreadable": "⚠️ Сертификат не читается — неверный пароль или повреждённый файл", + "overpayCertMissing": "❌ Сертификат не загружен", + "overpayCertEnvLockedPath": "Путь задан через переменную окружения OVERPAY_P12_PATH — значение из БД не применяется", + "overpayCertEnvLockedPassphrase": "Пароль задан через переменную окружения OVERPAY_P12_PASSPHRASE — значение из БД не применяется", + "overpayCertFile": "Файл сертификата (.p12 / .pfx)", + "overpayCertChooseFile": "Выбрать файл", + "overpayCertPassphrase": "Пароль сертификата", + "overpayCertPassphrasePlaceholder": "Пусто, если без пароля", + "overpayCertUpload": "Загрузить", + "overpayCertDelete": "Удалить", + "overpayCertConfirmDelete": "Удалить сертификат Overpay?", + "overpayCertFileTooLarge": "Файл больше 1 МБ", + "overpayCertUploadError": "Не удалось загрузить сертификат", + "overpayCertDeleteError": "Не удалось удалить сертификат", "conditions": "Условия отображения", "userTypeFilter": "Тип пользователя", "userTypeAll": "Все", diff --git a/src/locales/zh.json b/src/locales/zh.json index 093bac1..5e178ed 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1464,7 +1464,23 @@ "quickAmountsAdd": "添加", "quickAmountsInvalid": "请输入正的卢布金额", "quickAmountsLimit": "最多 10 个金额", - "quickAmountsRemove": "删除金额 {{value}} ₽" + "quickAmountsRemove": "删除金额 {{value}} ₽", + "overpayCertTitle": "mTLS 证书(Overpay)", + "overpayCertValid": "✅ 有效期至 {{date}}", + "overpayCertUnreadable": "⚠️ 证书无法读取——密码错误或文件损坏", + "overpayCertMissing": "❌ 未上传证书", + "overpayCertEnvLockedPath": "路径已通过环境变量 OVERPAY_P12_PATH 设置——数据库中的值不会生效", + "overpayCertEnvLockedPassphrase": "密码已通过环境变量 OVERPAY_P12_PASSPHRASE 设置——数据库中的值不会生效", + "overpayCertFile": "证书文件(.p12 / .pfx)", + "overpayCertChooseFile": "选择文件", + "overpayCertPassphrase": "证书密码", + "overpayCertPassphrasePlaceholder": "无密码请留空", + "overpayCertUpload": "上传", + "overpayCertDelete": "删除", + "overpayCertConfirmDelete": "确定删除 Overpay 证书?", + "overpayCertFileTooLarge": "文件超过 1 MB", + "overpayCertUploadError": "证书上传失败", + "overpayCertDeleteError": "证书删除失败" }, "emailTemplates": { "title": "邮件模板", diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index 9201b7e..c9ec788 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -1,14 +1,202 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, type ChangeEvent } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; +import { adminOverpayCertificateApi, OVERPAY_CERT_MAX_SIZE } from '../api/adminOverpayCertificate'; import { METHOD_LABELS } from '../constants/paymentMethods'; import type { PromoGroupSimple } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { useHapticFeedback } from '../platform/hooks/useHaptic'; +import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; +import { localeMap } from '../utils/withdrawalUtils'; import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons'; +function extractErrorDetail(err: unknown): string | null { + const error = err as { response?: { data?: { detail?: unknown } } }; + const detail = error.response?.data?.detail; + return typeof detail === 'string' ? detail : null; +} + +function OverpayCertificateSection() { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const confirm = useDestructiveConfirm(); + const fileInputRef = useRef(null); + const [certFile, setCertFile] = useState(null); + const [passphrase, setPassphrase] = useState(''); + const [certError, setCertError] = useState(null); + const [certWarning, setCertWarning] = useState(null); + + const { data: certStatus, isLoading } = useQuery({ + queryKey: ['admin', 'overpay-certificate'], + queryFn: adminOverpayCertificateApi.getStatus, + }); + + const uploadMutation = useMutation({ + mutationFn: (payload: { file: File; passphrase: string }) => + adminOverpayCertificateApi.upload(payload.file, payload.passphrase), + onSuccess: (resp) => { + haptic.success(); + setCertError(null); + setCertWarning(resp.warning ?? null); + setCertFile(null); + setPassphrase(''); + if (fileInputRef.current) fileInputRef.current.value = ''; + queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] }); + }, + onError: (err) => { + haptic.error(); + setCertWarning(null); + setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertUploadError')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: adminOverpayCertificateApi.remove, + onSuccess: () => { + haptic.success(); + setCertError(null); + setCertWarning(null); + queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] }); + }, + onError: (err) => { + haptic.error(); + setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertDeleteError')); + }, + }); + + const handleFileChange = (e: ChangeEvent) => { + setCertError(null); + setCertWarning(null); + const selected = e.target.files?.[0] ?? null; + if (selected && selected.size > OVERPAY_CERT_MAX_SIZE) { + setCertFile(null); + e.target.value = ''; + setCertError(t('admin.paymentMethods.overpayCertFileTooLarge')); + return; + } + setCertFile(selected); + }; + + const handleDelete = async () => { + const confirmed = await confirm(t('admin.paymentMethods.overpayCertConfirmDelete')); + if (confirmed) deleteMutation.mutate(); + }; + + const expiryDate = certStatus?.not_valid_after + ? new Date(certStatus.not_valid_after).toLocaleDateString(localeMap[i18n.language] || 'ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : null; + + return ( +
+

+ {t('admin.paymentMethods.overpayCertTitle')} +

+ + {isLoading ? ( +
+ ) : certStatus ? ( +
+ {certStatus.valid ? ( + <> +

+ {t('admin.paymentMethods.overpayCertValid', { date: expiryDate })} +

+ {certStatus.subject && ( +

{certStatus.subject}

+ )} + + ) : certStatus.uploaded ? ( +

+ {t('admin.paymentMethods.overpayCertUnreadable')} +

+ ) : ( +

{t('admin.paymentMethods.overpayCertMissing')}

+ )} + {certStatus.env_locked_path && ( +

+ {t('admin.paymentMethods.overpayCertEnvLockedPath')} +

+ )} + {certStatus.env_locked_passphrase && ( +

+ {t('admin.paymentMethods.overpayCertEnvLockedPassphrase')} +

+ )} +
+ ) : null} + +
+ + + +
+ +
+ + setPassphrase(e.target.value)} + placeholder={t('admin.paymentMethods.overpayCertPassphrasePlaceholder')} + className="input" + /> +
+ + {certError &&

{certError}

} + {certWarning &&

{certWarning}

} + +
+ + {certStatus?.uploaded && ( + + )} +
+
+ ); +} + export default function AdminPaymentMethodEdit() { const { t } = useTranslation(); const { methodId } = useParams<{ methodId: string }>(); @@ -510,6 +698,8 @@ export default function AdminPaymentMethodEdit() {
+ {config.method_id === 'overpay' && } + {/* Actions */}