mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(admin): overpay certificate upload block
(cherry picked from commit b51b9d7d45db7b4597838d25a475291e453c7a7a)
This commit is contained in:
48
src/api/adminOverpayCertificate.ts
Normal file
48
src/api/adminOverpayCertificate.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "کمپینهای تبلیغاتی",
|
||||
|
||||
@@ -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": "Все",
|
||||
|
||||
@@ -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": "邮件模板",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user