Compare commits
3 Commits
7e403bd53e
...
fd5eb9dd1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd5eb9dd1d | ||
|
|
5c13dd2f29 | ||
|
|
b161ecf815 |
@ -13,6 +13,8 @@ import { WikiController } from './wiki.controller';
|
|||||||
import { WikiService } from './wiki.service';
|
import { WikiService } from './wiki.service';
|
||||||
import { PetsController } from './pets.controller';
|
import { PetsController } from './pets.controller';
|
||||||
import { PetsService } from './pets.service';
|
import { PetsService } from './pets.service';
|
||||||
|
import { SslController } from './ssl.controller';
|
||||||
|
import { SslService } from './ssl.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ import { RedisModule } from '../redis/redis.module';
|
|||||||
BlogsController,
|
BlogsController,
|
||||||
WikiController,
|
WikiController,
|
||||||
PetsController,
|
PetsController,
|
||||||
|
SslController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminService,
|
AdminService,
|
||||||
@ -35,6 +38,7 @@ import { RedisModule } from '../redis/redis.module';
|
|||||||
BlogsService,
|
BlogsService,
|
||||||
WikiService,
|
WikiService,
|
||||||
PetsService,
|
PetsService,
|
||||||
|
SslService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -39,6 +39,17 @@ export class MediaController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('bulk')
|
||||||
|
@ApiOperation({ summary: 'حذف دستهجمعی فایلها' })
|
||||||
|
async deleteManyMedia(@Body('ids') ids: string[]) {
|
||||||
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
|
throw new BadRequestException('لیست شناسههای رسانه الزامی است');
|
||||||
|
}
|
||||||
|
const data = await this.mediaService.deleteManyMedia(ids);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'حذف فایل' })
|
@ApiOperation({ summary: 'حذف فایل' })
|
||||||
|
|||||||
@ -62,6 +62,33 @@ export class MediaService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteManyMedia(ids: string[]) {
|
||||||
|
const mediaItems = await this.prisma.media.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const media of mediaItems) {
|
||||||
|
const filePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'uploads',
|
||||||
|
path.basename(media.url),
|
||||||
|
);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore individual unlink errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.media.deleteMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, count: mediaItems.length };
|
||||||
|
}
|
||||||
|
|
||||||
async updateMedia(
|
async updateMedia(
|
||||||
id: string,
|
id: string,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
100
backend/src/admin/ssl.controller.ts
Normal file
100
backend/src/admin/ssl.controller.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
Query,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { SslService } from './ssl.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ApiTags('Admin SSL - مدیریت گواهیهای امنیتی SSL')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin')
|
||||||
|
@Controller('admin/ssl')
|
||||||
|
export class SslController {
|
||||||
|
constructor(private readonly sslService: SslService) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@ApiOperation({ summary: 'دریافت وضعیت و جزئیات گواهی SSL فعلی سرور' })
|
||||||
|
getStatus() {
|
||||||
|
const status = this.sslService.getStatus();
|
||||||
|
return { success: true, data: status };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('update-text')
|
||||||
|
@ApiOperation({ summary: 'بهروزرسانی گواهی SSL از طریق متن و کلید' })
|
||||||
|
updateByText(
|
||||||
|
@Body('certificate') certificate: string,
|
||||||
|
@Body('privateKey') privateKey: string,
|
||||||
|
) {
|
||||||
|
return this.sslService.updateCertificates(certificate, privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiOperation({ summary: 'آپلود فایلهای گواهی SSL (FullChain/CRT و PrivateKey)' })
|
||||||
|
uploadFiles(
|
||||||
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
|
@Body('certificateText') certText?: string,
|
||||||
|
@Body('privateKeyText') keyText?: string,
|
||||||
|
) {
|
||||||
|
let certContent = certText || '';
|
||||||
|
let keyContent = keyText || '';
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
for (const file of files) {
|
||||||
|
const content = file.buffer.toString('utf8');
|
||||||
|
const fieldName = file.fieldname.toLowerCase();
|
||||||
|
const originalName = file.originalname.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldName.includes('cert') ||
|
||||||
|
fieldName.includes('fullchain') ||
|
||||||
|
fieldName.includes('crt') ||
|
||||||
|
originalName.endsWith('.crt') ||
|
||||||
|
originalName.includes('fullchain') ||
|
||||||
|
originalName.includes('cert') ||
|
||||||
|
content.includes('BEGIN CERTIFICATE')
|
||||||
|
) {
|
||||||
|
certContent = content;
|
||||||
|
} else if (
|
||||||
|
fieldName.includes('key') ||
|
||||||
|
originalName.endsWith('.key') ||
|
||||||
|
originalName.includes('priv') ||
|
||||||
|
content.includes('BEGIN PRIVATE KEY') ||
|
||||||
|
content.includes('BEGIN RSA PRIVATE KEY') ||
|
||||||
|
content.includes('BEGIN EC PRIVATE KEY')
|
||||||
|
) {
|
||||||
|
keyContent = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certContent || !keyContent) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'ارسال هر دو فایل گواهی (Fullchain/CRT) و کلید خصوصی (Private Key) الزامی است.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sslService.updateCertificates(certContent, keyContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-domain')
|
||||||
|
@ApiOperation({ summary: 'بررسی آنلاین گواهی SSL یک دامنه مشخص روی پورت ۴۴۳' })
|
||||||
|
testDomain(@Query('domain') domain: string) {
|
||||||
|
if (!domain) {
|
||||||
|
throw new BadRequestException('نام دامنه الزامی است');
|
||||||
|
}
|
||||||
|
return this.sslService.testOnlineDomainSsl(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
backend/src/admin/ssl.service.ts
Normal file
214
backend/src/admin/ssl.service.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
export interface SslCertInfo {
|
||||||
|
exists: boolean;
|
||||||
|
subject?: string;
|
||||||
|
issuer?: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
validFromShamsi?: string;
|
||||||
|
validToShamsi?: string;
|
||||||
|
daysRemaining?: number;
|
||||||
|
isExpired?: boolean;
|
||||||
|
domains?: string[];
|
||||||
|
fingerprint?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SslService {
|
||||||
|
private readonly logger = new Logger(SslService.name);
|
||||||
|
private readonly certsDir = process.env.SSL_CERTS_DIR || path.join(process.cwd(), 'ssl_certs');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!fs.existsSync(this.certsDir)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.certsDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not create ssl_certs dir at ${this.certsDir}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCertPath(): string {
|
||||||
|
return path.join(this.certsDir, 'fullchain.pem');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKeyPath(): string {
|
||||||
|
return path.join(this.certsDir, 'privateKey.pem');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toShamsi(date: Date): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
} catch {
|
||||||
|
return date.toLocaleDateString('fa-IR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): SslCertInfo {
|
||||||
|
const certPath = this.getCertPath();
|
||||||
|
if (!fs.existsSync(certPath)) {
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certPem = fs.readFileSync(certPath, 'utf8');
|
||||||
|
const cert = new crypto.X509Certificate(certPem);
|
||||||
|
|
||||||
|
const validFromDate = new Date(cert.validFrom);
|
||||||
|
const validToDate = new Date(cert.validTo);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = validToDate.getTime() - now.getTime();
|
||||||
|
const daysRemaining = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||||
|
const isExpired = diffMs <= 0;
|
||||||
|
|
||||||
|
// Extract SAN domains
|
||||||
|
let domains: string[] = [];
|
||||||
|
if (cert.subjectAltName) {
|
||||||
|
domains = cert.subjectAltName
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().replace(/^DNS:/, ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
} else if (cert.subject) {
|
||||||
|
domains = [cert.subject];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
subject: cert.subject,
|
||||||
|
issuer: cert.issuer,
|
||||||
|
validFrom: validFromDate.toISOString(),
|
||||||
|
validTo: validToDate.toISOString(),
|
||||||
|
validFromShamsi: this.toShamsi(validFromDate),
|
||||||
|
validToShamsi: this.toShamsi(validToDate),
|
||||||
|
daysRemaining,
|
||||||
|
isExpired,
|
||||||
|
domains,
|
||||||
|
fingerprint: cert.fingerprint256 || cert.fingerprint,
|
||||||
|
serialNumber: cert.serialNumber,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to parse SSL certificate', error);
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateCertificates(certContent: string, keyContent: string) {
|
||||||
|
const cleanCert = certContent.trim();
|
||||||
|
const cleanKey = keyContent.trim();
|
||||||
|
|
||||||
|
if (!cleanCert || !cleanKey) {
|
||||||
|
throw new BadRequestException('محتوای گواهی (Certificate) و کلید خصوصی (Private Key) الزامی است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Validate Certificate format
|
||||||
|
let cert: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
cert = new crypto.X509Certificate(cleanCert);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as Error;
|
||||||
|
throw new BadRequestException(`فرمت گواهی نامعتبر است: ${errObj.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate Private Key format
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(cleanKey);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as Error;
|
||||||
|
throw new BadRequestException(`فرمت کلید خصوصی نامعتبر است: ${errObj.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ensure certs directory exists
|
||||||
|
if (!fs.existsSync(this.certsDir)) {
|
||||||
|
fs.mkdirSync(this.certsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save backup if existing
|
||||||
|
const certPath = this.getCertPath();
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
if (fs.existsSync(certPath)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(certPath, `${certPath}.bak`);
|
||||||
|
fs.copyFileSync(keyPath, `${keyPath}.bak`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not create cert backup: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Write new certificate files
|
||||||
|
fs.writeFileSync(certPath, cleanCert, 'utf8');
|
||||||
|
fs.writeFileSync(keyPath, cleanKey, 'utf8');
|
||||||
|
|
||||||
|
this.logger.log(`SSL Certificates updated successfully. Subject: ${cert.subject}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'گواهی SSL با موفقیت بهروزرسانی و در سرور ذخیره شد.',
|
||||||
|
status: this.getStatus(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public testOnlineDomainSsl(domain: string): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '').trim();
|
||||||
|
const tls = require('tls');
|
||||||
|
const socket = tls.connect(
|
||||||
|
{
|
||||||
|
host: cleanDomain,
|
||||||
|
port: 443,
|
||||||
|
servername: cleanDomain,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const peerCert = socket.getPeerCertificate(true);
|
||||||
|
socket.end();
|
||||||
|
|
||||||
|
if (!peerCert || Object.keys(peerCert).length === 0) {
|
||||||
|
return resolve({
|
||||||
|
onlineCheck: false,
|
||||||
|
message: 'گواهی SSL بر روی پورت ۴۴۳ این دامنه یافت نشد.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTo = new Date(peerCert.valid_to);
|
||||||
|
resolve({
|
||||||
|
onlineCheck: true,
|
||||||
|
domain: cleanDomain,
|
||||||
|
authorized: socket.authorized,
|
||||||
|
validTo: validTo.toISOString(),
|
||||||
|
validToShamsi: this.toShamsi(validTo),
|
||||||
|
issuer: peerCert.issuer?.O || peerCert.issuer?.CN,
|
||||||
|
subject: peerCert.subject?.CN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on('error', (err: Error) => {
|
||||||
|
resolve({
|
||||||
|
onlineCheck: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({
|
||||||
|
onlineCheck: false,
|
||||||
|
error: 'مهلت اتصال (Timeout) به پایان رسید.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall, Sparkles, MessageSquareQuote, FlaskConical, Building2, Globe, DollarSign, Sliders, MessageSquare, Receipt, CreditCard } from 'lucide-react';
|
import { Users, ShoppingCart, Tag, Settings, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall, Sparkles, MessageSquareQuote, FlaskConical, Building2, Globe, DollarSign, Sliders, MessageSquare, Receipt, CreditCard } from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
|
|
||||||
const menuGroups = [
|
const menuGroups = [
|
||||||
{
|
{
|
||||||
@ -65,9 +64,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
||||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -88,18 +85,6 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await api.post('/auth/logout');
|
|
||||||
} catch {
|
|
||||||
// Ignore logout errors
|
|
||||||
} finally {
|
|
||||||
clearAuth();
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Backdrop */}
|
{/* Mobile Backdrop */}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ const SEARCHABLE_PAGES = [
|
|||||||
{ label: 'مدیریت مقالات وبلاگ', path: '/blogs' },
|
{ label: 'مدیریت مقالات وبلاگ', path: '/blogs' },
|
||||||
{ label: 'مدیریت دانشنامه و مقالات علمی (Wiki)', path: '/wiki' },
|
{ label: 'مدیریت دانشنامه و مقالات علمی (Wiki)', path: '/wiki' },
|
||||||
{ label: 'تنظیمات کلی سیستم و درگاه پرداخت', path: '/settings' },
|
{ label: 'تنظیمات کلی سیستم و درگاه پرداخت', path: '/settings' },
|
||||||
|
{ label: 'مدیریت گواهی امنیتی SSL و HTTPS', path: '/settings/ssl' },
|
||||||
|
{ label: 'تنظیمات درگاه پیامک (MeliPayamak)', path: '/settings/sms' },
|
||||||
{ label: 'متون رابط کاربری و ترجمهها', path: '/ui-texts' },
|
{ label: 'متون رابط کاربری و ترجمهها', path: '/ui-texts' },
|
||||||
{ label: 'مدیریت رسانه، عکسها و گالری', path: '/media' },
|
{ label: 'مدیریت رسانه، عکسها و گالری', path: '/media' },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -29,18 +29,67 @@ export default function MediaManager() {
|
|||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||||
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const fetchMedia = useCallback(async () => {
|
const fetchMedia = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/media');
|
const res = await api.get('/admin/media');
|
||||||
setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||||||
|
setSelectedIds(new Set());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch media', error);
|
console.error('Failed to fetch media', error);
|
||||||
toast.error('خطا در دریافت رسانهها');
|
toast.error('خطا در دریافت رسانهها');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelect = (id: string, e?: React.MouseEvent) => {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllCurrentPage = (items: Media[]) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const allSelected = items.every(m => prev.has(m.id));
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (allSelected) {
|
||||||
|
items.forEach(m => next.delete(m.id));
|
||||||
|
} else {
|
||||||
|
items.forEach(m => next.add(m.id));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
try {
|
||||||
|
setIsBulkDeleting(true);
|
||||||
|
await api.delete('/admin/media/bulk', {
|
||||||
|
data: { ids: Array.from(selectedIds) }
|
||||||
|
});
|
||||||
|
toast.success(`${selectedIds.size} تصویر با موفقیت حذف شد`);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setIsBulkDeleteModalOpen(false);
|
||||||
|
fetchMedia();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk delete failed', error);
|
||||||
|
toast.error('خطا در حذف گروهی تصاویر');
|
||||||
|
} finally {
|
||||||
|
setIsBulkDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const uploadFile = useCallback(async (file: File) => {
|
const uploadFile = useCallback(async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@ -241,18 +290,73 @@ export default function MediaManager() {
|
|||||||
) : (() => {
|
) : (() => {
|
||||||
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
||||||
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
||||||
|
const isAllPageSelected = paginatedList.length > 0 && paginatedList.every(m => selectedIds.has(m.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
|
{/* Multi-select Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 bg-purple-50/60 p-3 rounded-2xl border border-purple-100 font-vazir">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectAllCurrentPage(paginatedList)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-white border border-purple-200 rounded-xl text-xs font-bold text-purple-700 hover:bg-purple-100/50 transition-all cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||||
|
isAllPageSelected ? 'bg-purple-600 border-purple-600 text-white' : 'border-purple-300 bg-white'
|
||||||
|
}`}>
|
||||||
|
{isAllPageSelected && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
<span>{isAllPageSelected ? 'لغو انتخاب این صفحه' : 'انتخاب همه در این صفحه'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<span className="text-xs font-black text-purple-900 bg-purple-100 px-2.5 py-1 rounded-lg">
|
||||||
|
{selectedIds.size} مورد انتخاب شده
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-xl transition-all shadow-md shadow-red-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span>حذف موارد انتخابشده ({selectedIds.size})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{paginatedList.map(media => {
|
{paginatedList.map(media => {
|
||||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||||
|
const isSelected = selectedIds.has(media.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={media.id}
|
key={media.id}
|
||||||
className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer"
|
className={`group relative bg-gray-100 rounded-xl overflow-hidden border transition-all cursor-pointer ${
|
||||||
|
isSelected ? 'border-purple-600 ring-2 ring-purple-400/50 shadow-md bg-purple-50/20' : 'border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100'
|
||||||
|
}`}
|
||||||
onClick={() => setPreviewUrl(imgUrl)}
|
onClick={() => setPreviewUrl(imgUrl)}
|
||||||
>
|
>
|
||||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100 relative">
|
||||||
|
{/* Checkbox badge */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => toggleSelect(media.id, e)}
|
||||||
|
className={`absolute top-2 right-2 z-20 w-6 h-6 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-purple-600 text-white shadow-md'
|
||||||
|
: 'bg-white/80 border border-gray-300 text-transparent hover:border-purple-400 opacity-80 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
title="انتخاب"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className={`w-4 h-4 ${isSelected ? 'text-white' : 'text-gray-400'}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={imgUrl}
|
src={imgUrl}
|
||||||
alt={media.filename}
|
alt={media.filename}
|
||||||
@ -315,6 +419,16 @@ export default function MediaManager() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirm Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isBulkDeleteModalOpen}
|
||||||
|
title="حذف گروهی تصاویر"
|
||||||
|
message={`آیا از حذف دستهجمعی ${selectedIds.size} تصویر انتخاب شده مطمئن هستید؟ این عملیات غیرقابل بازگشت است.`}
|
||||||
|
onConfirm={handleBulkDelete}
|
||||||
|
onCancel={() => setIsBulkDeleteModalOpen(false)}
|
||||||
|
isLoading={isBulkDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/80 backdrop-blur-sm"
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/80 backdrop-blur-sm"
|
||||||
|
|||||||
@ -659,25 +659,25 @@ export default function Products() {
|
|||||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span>منطق و دوز دقیق مصرفی بالینی (Dosage Calculator Fields)</span>
|
<span>دستور مصرف بالینی و راهنمای دوز مصرفی</span>
|
||||||
<div className="group relative inline-block">
|
<div className="group relative inline-block">
|
||||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-64 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-64 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||||
دستور مصرف دقیق کالا (مانند: ۲ قرص به ازای هر ۱۰ کیلوگرم وزن بدن در روز). این متن در کاتالوگ و کارت مشخصات کالا به صورت برجسته نمایش داده میشود.
|
دستور مصرف روان و فارسی کالا (مانند: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن). این متن در کاتالوگ آنلاین و صفحه مشخصات کالا به صورت برجسته نمایش داده میشود.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-purple-600 font-bold">دستور مصرف بالینی / JSON</span>
|
<span className="text-xs text-purple-600 font-bold">متن راهنمای بالینی</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={formData.dosageLogic}
|
value={formData.dosageLogic}
|
||||||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
||||||
placeholder='مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن یا فرمت JSON: {"baseDosage": 1, "perKg": 10}'
|
placeholder="مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن، همراه با وعده غذایی مصرف شود."
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-vazir"
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-vazir"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-400">
|
<p className="text-[11px] text-gray-400">
|
||||||
این متن در بخش مشخصات علمی کالا و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
این متن در بخش مشخصات علمی کالا، ماشینحساب دوز و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -108,6 +108,13 @@ export default function Settings() {
|
|||||||
<p className="text-gray-500 font-medium mt-1">مدیریت پارامترهای اصلی و سیستمی کانینا</p>
|
<p className="text-gray-500 font-medium mt-1">مدیریت پارامترهای اصلی و سیستمی کانینا</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Link
|
||||||
|
to="/settings/ssl"
|
||||||
|
className="bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-4 h-4 text-emerald-600" />
|
||||||
|
مدیریت گواهی SSL و امنیت
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/settings/sms"
|
to="/settings/sms"
|
||||||
className="bg-purple-50 text-purple-700 hover:bg-purple-100 border border-purple-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all flex items-center gap-2"
|
className="bg-purple-50 text-purple-700 hover:bg-purple-100 border border-purple-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all flex items-center gap-2"
|
||||||
@ -402,13 +409,22 @@ export default function Settings() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-700 mb-1">لینک لوگوی برند (Brand Logo URL)</label>
|
<label className="block text-xs font-bold text-gray-700 mb-1">لینک لوگوی برند (Brand Logo URL)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.BRAND_LOGO_URL}
|
value={settings.BRAND_LOGO_URL}
|
||||||
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
|
||||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
className="flex-1 border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||||
|
placeholder="https://... یا /logo.png"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
|
{settings.BRAND_LOGO_URL && (
|
||||||
|
<div className="w-10 h-10 border rounded-lg bg-gray-50 flex items-center justify-center p-1 overflow-hidden shrink-0">
|
||||||
|
<img src={settings.BRAND_LOGO_URL} alt="Preview" className="max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">این لوگو در صورت پر بودن، جایگزین تایپوگرافی هدر سایت در اپلیکیشن خواهد شد.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
|
|||||||
548
frontend/admin-panel/src/pages/SslSettingsPage.tsx
Normal file
548
frontend/admin-panel/src/pages/SslSettingsPage.tsx
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldAlert,
|
||||||
|
Upload,
|
||||||
|
FileCode,
|
||||||
|
Key,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Globe,
|
||||||
|
Calendar,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
Lock,
|
||||||
|
Clock,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import api from '../services/api';
|
||||||
|
import Spinner from '../components/ui/Spinner';
|
||||||
|
|
||||||
|
interface SslStatus {
|
||||||
|
exists: boolean;
|
||||||
|
subject?: string;
|
||||||
|
issuer?: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
validFromShamsi?: string;
|
||||||
|
validToShamsi?: string;
|
||||||
|
daysRemaining?: number;
|
||||||
|
isExpired?: boolean;
|
||||||
|
domains?: string[];
|
||||||
|
fingerprint?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SslSettingsPage() {
|
||||||
|
const [status, setStatus] = useState<SslStatus | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [isTestingOnline, setIsTestingOnline] = useState(false);
|
||||||
|
const [onlineResult, setOnlineResult] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [testDomainInput, setTestDomainInput] = useState('canina.ir');
|
||||||
|
|
||||||
|
// Text inputs
|
||||||
|
const [activeTab, setActiveTab] = useState<'upload' | 'text'>('upload');
|
||||||
|
const [certText, setCertText] = useState('');
|
||||||
|
const [keyText, setKeyText] = useState('');
|
||||||
|
|
||||||
|
// File state
|
||||||
|
const [certFile, setCertFile] = useState<File | null>(null);
|
||||||
|
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||||
|
const certInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const keyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await api.get('/admin/ssl/status');
|
||||||
|
setStatus(res.data?.data || res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch SSL status:', err);
|
||||||
|
toast.error('خطا در دریافت وضعیت گواهی SSL');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileUploadSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!certFile || !keyFile) {
|
||||||
|
toast.error('لطفاً هر دو فایل گواهی (CRT/Fullchain) و کلید خصوصی (Key) را انتخاب کنید.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdating(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('certificate', certFile);
|
||||||
|
formData.append('privateKey', keyFile);
|
||||||
|
|
||||||
|
const res = await api.post('/admin/ssl/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(res.data?.message || 'گواهی SSL با موفقیت بهروزرسانی شد');
|
||||||
|
setCertFile(null);
|
||||||
|
setKeyFile(null);
|
||||||
|
fetchStatus();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } };
|
||||||
|
toast.error(errObj.response?.data?.message || 'خطا در آپلود گواهی SSL');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!certText.trim() || !keyText.trim()) {
|
||||||
|
toast.error('لطفاً متن هر دو فیلد گواهی و کلید خصوصی را وارد کنید.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdating(true);
|
||||||
|
const res = await api.post('/admin/ssl/update-text', {
|
||||||
|
certificate: certText,
|
||||||
|
privateKey: keyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(res.data?.message || 'گواهی SSL با موفقیت ذخیره و فعال شد');
|
||||||
|
setCertText('');
|
||||||
|
setKeyText('');
|
||||||
|
fetchStatus();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } };
|
||||||
|
toast.error(errObj.response?.data?.message || 'خطا در اعمال گواهی SSL');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestOnline = async () => {
|
||||||
|
if (!testDomainInput.trim()) return;
|
||||||
|
try {
|
||||||
|
setIsTestingOnline(true);
|
||||||
|
setOnlineResult(null);
|
||||||
|
const res = await api.get('/admin/ssl/test-domain', {
|
||||||
|
params: { domain: testDomainInput.trim() },
|
||||||
|
});
|
||||||
|
setOnlineResult(res.data);
|
||||||
|
if (res.data?.onlineCheck) {
|
||||||
|
toast.success(`گواهی دامنه ${testDomainInput} معتبر است`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || res.data?.error || 'گواهی برای این دامنه تایید نشد');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('خطا در تست آنلاین دامنه');
|
||||||
|
} finally {
|
||||||
|
setIsTestingOnline(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, label: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${label} کپی شد`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 font-vazir" dir="rtl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||||
|
<Lock className="w-6 h-6 text-emerald-600" />
|
||||||
|
مدیریت گواهی امنیتی SSL و HTTPS
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 font-medium mt-1 text-xs">
|
||||||
|
مشاهده اعتبار، تاریخ انقضا و بهروزرسانی آنی گواهیهای امنیتی سرور
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-700 hover:bg-gray-50 transition-all flex items-center gap-2 shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-gray-500 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
<span>بروزرسانی وضعیت</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSL Status Card */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-white p-12 rounded-2xl border border-gray-200 shadow-sm flex flex-col items-center justify-center gap-3">
|
||||||
|
<Spinner size="lg" className="text-purple-600" />
|
||||||
|
<span className="text-xs text-gray-400 font-bold">در حال استعلام وضعیت گواهی سرور...</span>
|
||||||
|
</div>
|
||||||
|
) : status?.exists ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Status Hero */}
|
||||||
|
<div className={`lg:col-span-2 rounded-2xl p-6 border shadow-sm flex flex-col justify-between ${
|
||||||
|
status.isExpired
|
||||||
|
? 'bg-red-50/50 border-red-200'
|
||||||
|
: (status.daysRemaining ?? 0) <= 15
|
||||||
|
? 'bg-amber-50/50 border-amber-200'
|
||||||
|
: 'bg-emerald-50/40 border-emerald-200'
|
||||||
|
}`}>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-black/5 pb-4 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-md ${
|
||||||
|
status.isExpired ? 'bg-red-600' : (status.daysRemaining ?? 0) <= 15 ? 'bg-amber-500' : 'bg-emerald-600'
|
||||||
|
}`}>
|
||||||
|
{status.isExpired ? <ShieldAlert className="w-6 h-6" /> : <ShieldCheck className="w-6 h-6" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-black text-gray-900">
|
||||||
|
{status.isExpired
|
||||||
|
? 'گواهی منقضی شده است!'
|
||||||
|
: (status.daysRemaining ?? 0) <= 15
|
||||||
|
? 'هشدار: گواهی به زودی منقضی میشود'
|
||||||
|
: 'گواهی امنیتی SSL فعال و معتبر است'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 font-bold mt-0.5">
|
||||||
|
صادرکننده: <span className="font-mono text-gray-700">{status.issuer || 'نامشخص'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-left">
|
||||||
|
<span className={`text-2xl font-black font-vazir ${
|
||||||
|
status.isExpired ? 'text-red-600' : (status.daysRemaining ?? 0) <= 15 ? 'text-amber-600' : 'text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{status.daysRemaining} روز
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold">باقیمانده تا انقضا</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Information Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-2">
|
||||||
|
<div className="p-3.5 bg-white/80 rounded-xl border border-gray-200/70 space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold text-gray-500">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-purple-600" />
|
||||||
|
<span>تاریخ صدور و شروع اعتبار</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-black text-gray-900">{status.validFromShamsi}</p>
|
||||||
|
<p className="text-[10px] font-mono text-gray-400" dir="ltr">{status.validFrom?.split('T')[0]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 bg-white/80 rounded-xl border border-gray-200/70 space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-bold text-gray-500">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
|
<span>تاریخ پایان و انقضای گواهی</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-black text-gray-900">{status.validToShamsi}</p>
|
||||||
|
<p className="text-[10px] font-mono text-gray-400" dir="ltr">{status.validTo?.split('T')[0]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Covered Domains */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-black/5">
|
||||||
|
<span className="text-[11px] font-bold text-gray-500 block mb-2">دامنههای تحت پوشش (Subject Alternative Names):</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{status.domains?.map((dom, idx) => (
|
||||||
|
<span key={idx} className="px-2.5 py-1 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 shadow-2xs font-bold flex items-center gap-1">
|
||||||
|
<Globe className="w-3 h-3 text-blue-500" />
|
||||||
|
{dom}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical Info & Fingerprint */}
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-sm space-y-4 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-gray-900 border-b border-gray-100 pb-3 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-600" />
|
||||||
|
مشخصات فنی و امنیتی
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] text-gray-400 block mb-0.5">شماره سریال گواهی (Serial Number):</span>
|
||||||
|
<span className="font-mono text-[11px] text-gray-700 bg-gray-50 px-2 py-1 rounded block truncate" dir="ltr">
|
||||||
|
{status.serialNumber || '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className="text-[11px] text-gray-400">اثر انگشت SHA-256:</span>
|
||||||
|
{status.fingerprint && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(status.fingerprint!, 'اثر انگشت')}
|
||||||
|
className="text-[10px] text-purple-600 hover:underline flex items-center gap-0.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" /> کپی
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] text-gray-600 bg-gray-50 p-1.5 rounded block break-all" dir="ltr">
|
||||||
|
{status.fingerprint || '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-50 rounded-xl border border-blue-100 text-[11px] text-blue-900 leading-relaxed font-medium">
|
||||||
|
💡 سرور Traefik به صورت زنده تغییرات این گواهی را شناسایی کرده و بدون قطع شدن سرویسدهی، گواهی جدید را اعمال میکند.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-amber-50 p-6 rounded-2xl border border-amber-200 shadow-sm flex items-start gap-4">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-amber-900">هیچ گواهی اختصاصی فعالی یافت نشد</h4>
|
||||||
|
<p className="text-xs text-amber-700 mt-1 leading-relaxed">
|
||||||
|
هنوز فایلی در مسیر گواهیهای اختصاصی Canina قرار نگرفته است. لطفاً فایلهای Fullchain و Private Key گواهی خریداریشده خود را در فرم زیر آپلود کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload / Update Form */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-black text-gray-900 flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-purple-600" />
|
||||||
|
بهروزرسانی و نصب گواهی جدید
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
فایلهای گواهی (CRT/Fullchain) و کلید خصوصی (Private Key) را بارگذاری یا متن آنها را پیست نمایید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex bg-gray-100 p-1 rounded-xl gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('upload')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'upload' ? 'bg-white text-purple-700 shadow-xs' : 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
آپلود فایلها (File Upload)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('text')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
|
activeTab === 'text' ? 'bg-white text-purple-700 shadow-xs' : 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ورود متن (PEM Content)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'upload' ? (
|
||||||
|
<form onSubmit={handleFileUploadSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Certificate File */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-gray-700 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<FileCode className="w-4 h-4 text-purple-600" />
|
||||||
|
فایل گواهی اصلی یا FullChain (.crt / .pem)
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">fullchain.pem</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={certInputRef}
|
||||||
|
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||||
|
accept=".crt,.pem,.cer,.txt"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => certInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-2xl p-6 flex flex-col items-center justify-center text-center cursor-pointer transition-all ${
|
||||||
|
certFile ? 'border-emerald-400 bg-emerald-50/30' : 'border-gray-200 hover:border-purple-400 hover:bg-purple-50/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{certFile ? (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-700 font-bold text-xs">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
||||||
|
<span className="truncate max-w-[200px]">{certFile.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">({(certFile.size / 1024).toFixed(1)} KB)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||||
|
<span className="text-xs font-bold text-gray-700">کلیک برای انتخاب فایل گواهی</span>
|
||||||
|
<span className="text-[10px] text-gray-400 mt-1">پسوندهای مجاز: .crt, .pem, .cer</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Private Key File */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-gray-700 flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Key className="w-4 h-4 text-amber-600" />
|
||||||
|
فایل کلید خصوصی (.key / .pem)
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">privateKey.pem</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={keyInputRef}
|
||||||
|
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||||
|
accept=".key,.pem,.txt"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => keyInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-2xl p-6 flex flex-col items-center justify-center text-center cursor-pointer transition-all ${
|
||||||
|
keyFile ? 'border-emerald-400 bg-emerald-50/30' : 'border-gray-200 hover:border-purple-400 hover:bg-purple-50/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{keyFile ? (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-700 font-bold text-xs">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
||||||
|
<span className="truncate max-w-[200px]">{keyFile.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">({(keyFile.size / 1024).toFixed(1)} KB)</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key className="w-8 h-8 text-gray-400 mb-2" />
|
||||||
|
<span className="text-xs font-bold text-gray-700">کلیک برای انتخاب فایل کلید خصوصی</span>
|
||||||
|
<span className="text-[10px] text-gray-400 mt-1">پسوندهای مجاز: .key, .pem</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUpdating || !certFile || !keyFile}
|
||||||
|
className="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-purple-200 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isUpdating ? <Spinner size="sm" /> : <ShieldCheck className="w-4 h-4" />}
|
||||||
|
<span>نصب و فعالسازی گواهی جدید</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleTextSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-gray-700 flex items-center justify-between">
|
||||||
|
<span>متن کامل گواهی (Certificate PEM)</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">-----BEGIN CERTIFICATE-----</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={certText}
|
||||||
|
onChange={(e) => setCertText(e.target.value)}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- MIIE... -----END CERTIFICATE-----"
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl text-[11px] font-mono outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black text-gray-700 flex items-center justify-between">
|
||||||
|
<span>متن کلید خصوصی (Private Key PEM)</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">-----BEGIN PRIVATE KEY-----</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={keyText}
|
||||||
|
onChange={(e) => setKeyText(e.target.value)}
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY----- MIIE... -----END PRIVATE KEY-----"
|
||||||
|
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl text-[11px] font-mono outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUpdating || !certText.trim() || !keyText.trim()}
|
||||||
|
className="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-purple-200 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isUpdating ? <Spinner size="sm" /> : <ShieldCheck className="w-4 h-4" />}
|
||||||
|
<span>ذخیره و اعمال تغییرات</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Online Domain Verification Tool */}
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||||||
|
<h3 className="text-sm font-black text-gray-900 flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-blue-600" />
|
||||||
|
ابزار بررسی آنلاین صحت گواهی SSL دامنهها (SSL Diagnostic)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={testDomainInput}
|
||||||
|
onChange={(e) => setTestDomainInput(e.target.value)}
|
||||||
|
placeholder="مثال: canina.ir یا api.canina.ir"
|
||||||
|
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-mono outline-none focus:border-blue-500"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestOnline}
|
||||||
|
disabled={isTestingOnline}
|
||||||
|
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-all shadow-xs flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isTestingOnline ? <Spinner size="sm" /> : <ExternalLink className="w-4 h-4" />}
|
||||||
|
<span>تست زنده پورت ۴۴۳ دامنه</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onlineResult && (
|
||||||
|
<div className="mt-3 p-4 bg-gray-50 rounded-xl border border-gray-200 text-xs space-y-1.5 animate-in fade-in duration-150">
|
||||||
|
{onlineResult.onlineCheck ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-700 font-bold">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||||
|
<span>اتصال امن SSL با موفقیت برقرار شد.</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 space-y-0.5 pt-1 text-[11px]">
|
||||||
|
<p>دامنه: <span className="font-mono text-gray-900">{String(onlineResult.domain)}</span></p>
|
||||||
|
<p>صادرکننده: <span className="font-mono text-gray-900">{String(onlineResult.issuer)}</span></p>
|
||||||
|
<p>انقضا: <span className="font-black text-gray-900">{String(onlineResult.validToShamsi)}</span> ({String(onlineResult.validTo).split('T')[0]})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-red-600 font-bold">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||||
|
<span>خطا: {String(onlineResult.error || onlineResult.message || 'عدم پاسخگویی پورت ۴۴۳')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ const SeoSettingsPage = lazy(() => import('../pages/SeoSettingsPage'));
|
|||||||
const FinancialSettingsPage = lazy(() => import('../pages/FinancialSettingsPage'));
|
const FinancialSettingsPage = lazy(() => import('../pages/FinancialSettingsPage'));
|
||||||
const SystemSettingsPage = lazy(() => import('../pages/SystemSettingsPage'));
|
const SystemSettingsPage = lazy(() => import('../pages/SystemSettingsPage'));
|
||||||
const SmsSettingsPage = lazy(() => import('../pages/SmsSettingsPage'));
|
const SmsSettingsPage = lazy(() => import('../pages/SmsSettingsPage'));
|
||||||
|
const SslSettingsPage = lazy(() => import('../pages/SslSettingsPage'));
|
||||||
const Transactions = lazy(() => import('../pages/Transactions'));
|
const Transactions = lazy(() => import('../pages/Transactions'));
|
||||||
|
|
||||||
export interface AdminRouteConfig {
|
export interface AdminRouteConfig {
|
||||||
@ -61,6 +62,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'transactions/*', element: <Transactions /> },
|
{ path: 'transactions/*', element: <Transactions /> },
|
||||||
{ path: 'coupons/*', element: <Coupons /> },
|
{ path: 'coupons/*', element: <Coupons /> },
|
||||||
{ path: 'settings', element: <Settings /> },
|
{ path: 'settings', element: <Settings /> },
|
||||||
|
{ path: 'settings/ssl', element: <SslSettingsPage /> },
|
||||||
{ path: 'settings/sms', element: <SmsSettingsPage /> },
|
{ path: 'settings/sms', element: <SmsSettingsPage /> },
|
||||||
{ path: 'settings/seo', element: <SeoSettingsPage /> },
|
{ path: 'settings/seo', element: <SeoSettingsPage /> },
|
||||||
{ path: 'settings/financial', element: <FinancialSettingsPage /> },
|
{ path: 'settings/financial', element: <FinancialSettingsPage /> },
|
||||||
|
|||||||
@ -18,9 +18,10 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function getBlogs() {
|
async function getBlogs() {
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir';
|
const rawApiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:4001/api';
|
||||||
|
const apiBase = rawApiUrl.endsWith('/api') ? rawApiUrl : `${rawApiUrl}/api`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiUrl}/api/blogs`, { next: { revalidate: 60 } });
|
const res = await fetch(`${apiBase}/blogs`, { next: { revalidate: 60 } });
|
||||||
if (!res.ok) throw new Error('Failed to fetch blogs');
|
if (!res.ok) throw new Error('Failed to fetch blogs');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.data || data).map((b: Record<string, unknown>) => ({
|
return (data.data || data).map((b: Record<string, unknown>) => ({
|
||||||
|
|||||||
@ -145,19 +145,29 @@ export default function Header({
|
|||||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link href="/" className="flex items-center gap-2 sm:gap-3 group shrink-0 min-w-0">
|
<Link href="/" className="flex items-center gap-2 sm:gap-3 group shrink-0">
|
||||||
|
{(getText('BRAND_LOGO_URL', '') || getText('site_logo', '')) ? (
|
||||||
|
<img
|
||||||
|
src={getText('BRAND_LOGO_URL', '') || getText('site_logo', '')}
|
||||||
|
alt={getText('brand_name_fa', "کانینا ایران")}
|
||||||
|
className="h-10 sm:h-12 w-auto max-w-[140px] sm:max-w-[180px] object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 min-w-[40px] min-h-[40px] sm:min-w-[48px] sm:min-h-[48px] bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic shrink-0">C</div>
|
<div className="w-10 h-10 sm:w-12 sm:h-12 min-w-[40px] min-h-[40px] sm:min-w-[48px] sm:min-h-[48px] bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic shrink-0">C</div>
|
||||||
<div className="flex flex-col justify-center min-w-0">
|
<div className="flex flex-col justify-center">
|
||||||
<span className="text-canina-blue font-black text-base sm:text-2xl tracking-tighter italic font-sans flex items-center gap-1 leading-none truncate">
|
<span className="text-canina-blue font-black text-base sm:text-2xl italic font-sans flex items-center gap-1 leading-none pl-1 whitespace-nowrap">
|
||||||
Canina
|
Canina
|
||||||
<span className="text-[11px] sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 font-vazir text-medical-gray-700">
|
<span className="text-[11px] sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 mr-0.5 font-vazir text-medical-gray-700">
|
||||||
{getText('brand_name_fa', "ایران")}
|
{getText('brand_name_fa', "ایران")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden sm:block text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-0.5 truncate">
|
<span className="hidden sm:block text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-1 whitespace-nowrap">
|
||||||
نماینده رسمی CANINA PHARMA GMBH GERMANY
|
نماینده رسمی CANINA PHARMA GMBH GERMANY
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,15 @@ import axios from 'axios';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useUserStore } from '../store/userStore';
|
import { useUserStore } from '../store/userStore';
|
||||||
|
|
||||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
const getBaseURL = () => {
|
||||||
|
if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'http://127.0.0.1:4001/api';
|
||||||
|
}
|
||||||
|
return process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api';
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseURL = getBaseURL();
|
||||||
|
|
||||||
export interface ApiErrorPayload {
|
export interface ApiErrorPayload {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@ -34,6 +34,7 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
- canina_prod_uploads:/app/uploads
|
- canina_prod_uploads:/app/uploads
|
||||||
|
- /opt/services/proxy/certs/canina:/app/ssl_certs
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.canina-api-prod.rule=Host(`api.canina.ir`)"
|
- "traefik.http.routers.canina-api-prod.rule=Host(`api.canina.ir`)"
|
||||||
|
|||||||
@ -34,6 +34,7 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
- canina_stage_uploads:/app/uploads
|
- canina_stage_uploads:/app/uploads
|
||||||
|
- /opt/services/proxy/certs/canina:/app/ssl_certs
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.canina-api-stage.rule=Host(`stageapi.canina.ir`)"
|
- "traefik.http.routers.canina-api-stage.rule=Host(`stageapi.canina.ir`)"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user