feat(admin): overpay certificate upload block

(cherry picked from commit b51b9d7d45db7b4597838d25a475291e453c7a7a)
This commit is contained in:
Boris Kovalskii
2026-06-11 22:27:29 +10:00
committed by Fringg
parent 6721e25df6
commit f52360449b
6 changed files with 305 additions and 3 deletions

View File

@@ -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<OverpayCertificateStatus> => {
const response = await apiClient.get<OverpayCertificateStatus>(
'/cabinet/admin/overpay/certificate',
);
return response.data;
},
upload: async (file: File, passphrase: string): Promise<OverpayCertificateUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
formData.append('passphrase', passphrase);
const response = await apiClient.post<OverpayCertificateUploadResponse>(
'/cabinet/admin/overpay/certificate',
formData,
);
return response.data;
},
remove: async (): Promise<void> => {
await apiClient.delete('/cabinet/admin/overpay/certificate');
},
};

View File

@@ -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",

View File

@@ -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": "کمپین‌های تبلیغاتی",

View File

@@ -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": "Все",

View File

@@ -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": "邮件模板",

View File

@@ -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<HTMLInputElement>(null);
const [certFile, setCertFile] = useState<File | null>(null);
const [passphrase, setPassphrase] = useState('');
const [certError, setCertError] = useState<string | null>(null);
const [certWarning, setCertWarning] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="card space-y-4">
<h3 className="text-sm font-semibold text-dark-200">
{t('admin.paymentMethods.overpayCertTitle')}
</h3>
{isLoading ? (
<div className="skeleton h-10 w-full rounded-xl" />
) : certStatus ? (
<div>
{certStatus.valid ? (
<>
<p className="text-sm text-success-400">
{t('admin.paymentMethods.overpayCertValid', { date: expiryDate })}
</p>
{certStatus.subject && (
<p className="mt-1 break-all text-xs text-dark-500">{certStatus.subject}</p>
)}
</>
) : certStatus.uploaded ? (
<p className="text-sm text-warning-400">
{t('admin.paymentMethods.overpayCertUnreadable')}
</p>
) : (
<p className="text-sm text-dark-400">{t('admin.paymentMethods.overpayCertMissing')}</p>
)}
{certStatus.env_locked_path && (
<p className="mt-1 text-xs text-dark-500">
{t('admin.paymentMethods.overpayCertEnvLockedPath')}
</p>
)}
{certStatus.env_locked_passphrase && (
<p className="mt-1 text-xs text-dark-500">
{t('admin.paymentMethods.overpayCertEnvLockedPassphrase')}
</p>
)}
</div>
) : null}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.overpayCertFile')}
</label>
<input
ref={fileInputRef}
type="file"
accept=".p12,.pfx"
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/50 p-4 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
>
{certFile ? certFile.name : t('admin.paymentMethods.overpayCertChooseFile')}
</button>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.overpayCertPassphrase')}
</label>
<input
type="password"
autoComplete="off"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t('admin.paymentMethods.overpayCertPassphrasePlaceholder')}
className="input"
/>
</div>
{certError && <p className="text-sm text-error-400">{certError}</p>}
{certWarning && <p className="text-sm text-warning-400">{certWarning}</p>}
<div className="flex gap-3">
<button
type="button"
onClick={() => certFile && uploadMutation.mutate({ file: certFile, passphrase })}
disabled={!certFile || uploadMutation.isPending}
className="btn-primary flex flex-1 items-center justify-center gap-2"
>
{uploadMutation.isPending && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
{t('admin.paymentMethods.overpayCertUpload')}
</button>
{certStatus?.uploaded && (
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="btn-danger"
>
{t('admin.paymentMethods.overpayCertDelete')}
</button>
)}
</div>
</div>
);
}
export default function AdminPaymentMethodEdit() {
const { t } = useTranslation();
const { methodId } = useParams<{ methodId: string }>();
@@ -510,6 +698,8 @@ export default function AdminPaymentMethodEdit() {
</div>
</div>
{config.method_id === 'overpay' && <OverpayCertificateSection />}
{/* Actions */}
<div className="flex items-center gap-3">
<button onClick={() => navigate('/admin/payment-methods')} className="btn-secondary flex-1">