REST API→DB 토큰 배치 및 auth_tokens 저장 구현
This commit is contained in:
423
frontend/components/admin/AdvancedBatchModal.tsx
Normal file
423
frontend/components/admin/AdvancedBatchModal.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
|
||||
// BatchJobModal에서 사용하던 config_json 구조 확장
|
||||
interface RestApiConfigJson {
|
||||
sourceConnectionId?: number;
|
||||
targetConnectionId?: number;
|
||||
targetTable?: string;
|
||||
// REST API 관련 설정
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: string;
|
||||
apiBody?: string; // POST 요청용 Body
|
||||
// 매핑 정보 등
|
||||
mappings?: any[];
|
||||
}
|
||||
|
||||
interface AdvancedBatchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
job?: BatchJob | null;
|
||||
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
|
||||
}
|
||||
|
||||
export default function AdvancedBatchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
job,
|
||||
initialType = "rest_to_db",
|
||||
}: AdvancedBatchModalProps) {
|
||||
// 기본 BatchJob 정보 관리
|
||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
|
||||
// 상세 설정 (config_json 내부 값) 관리
|
||||
const [configData, setConfigData] = useState<RestApiConfigJson>({
|
||||
httpMethod: "GET", // 기본값
|
||||
apiBody: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
|
||||
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
|
||||
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadConnections();
|
||||
loadSchedulePresets();
|
||||
|
||||
if (job) {
|
||||
// 수정 모드
|
||||
setFormData({
|
||||
...job,
|
||||
config_json: job.config_json || {},
|
||||
});
|
||||
// 기존 config_json 내용을 상태로 복원
|
||||
const savedConfig = job.config_json as RestApiConfigJson;
|
||||
setConfigData({
|
||||
...savedConfig,
|
||||
httpMethod: savedConfig.httpMethod || "GET",
|
||||
apiBody: savedConfig.apiBody || "",
|
||||
});
|
||||
|
||||
// 타겟 연결이 있으면 테이블 목록 로드
|
||||
if (savedConfig.targetConnectionId) {
|
||||
loadTables(savedConfig.targetConnectionId);
|
||||
}
|
||||
} else {
|
||||
// 생성 모드
|
||||
setFormData({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
setConfigData({
|
||||
httpMethod: "GET",
|
||||
apiBody: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, job, initialType]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
// 외부 DB 연결 목록 조회 (내부 DB 포함)
|
||||
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setConnections(list);
|
||||
} catch (error) {
|
||||
console.error("연결 목록 조회 오류:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||
if (result.success && result.data) {
|
||||
setTargetTables(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedulePresets = async () => {
|
||||
try {
|
||||
const presets = await BatchAPI.getSchedulePresets();
|
||||
setSchedulePresets(presets);
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.job_name) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API URL 필수 체크
|
||||
if (!configData.apiUrl) {
|
||||
toast.error("API 서버 URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
|
||||
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
|
||||
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 최종 저장할 데이터 조립
|
||||
const finalJobData = {
|
||||
...formData,
|
||||
config_json: {
|
||||
...configData,
|
||||
// 추가적인 메타데이터가 필요하다면 여기에 포함
|
||||
},
|
||||
};
|
||||
|
||||
if (job?.id) {
|
||||
await BatchAPI.updateBatchJob(job.id, finalJobData);
|
||||
toast.success("배치 작업이 수정되었습니다.");
|
||||
} else {
|
||||
await BatchAPI.createBatchJob(finalJobData as BatchJob);
|
||||
toast.success("배치 작업이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("배치 저장 오류:", error);
|
||||
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
||||
{/* 1. 기본 정보 섹션 */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
|
||||
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">배치 타입 *</Label>
|
||||
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
|
||||
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
{formData.job_type === "rest_to_db"
|
||||
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
||||
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schedule_cron" className="text-xs">실행 스케줄 *</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
|
||||
placeholder="예: 0 12 * * *"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map(p => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="job_name" className="text-xs">배치명 *</Label>
|
||||
<Input
|
||||
id="job_name"
|
||||
value={formData.job_name || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
|
||||
placeholder="배치명을 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
className="mt-1 min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. REST API 설정 섹션 (Source) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🌐</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_url" className="text-xs">API 서버 URL *</Label>
|
||||
<Input
|
||||
id="api_url"
|
||||
value={configData.apiUrl || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
|
||||
placeholder="https://api.example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_key" className="text-xs">API 키 (선택)</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={configData.apiKey || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="endpoint" className="text-xs">엔드포인트 *</Label>
|
||||
<Input
|
||||
id="endpoint"
|
||||
value={configData.endpoint || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
|
||||
placeholder="/api/token"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="http_method" className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={configData.httpMethod || "GET"}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 생성/요청)</SelectItem>
|
||||
<SelectItem value="PUT">PUT (데이터 수정)</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE (데이터 삭제)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
||||
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
||||
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="api_body"
|
||||
value={configData.apiBody || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
|
||||
placeholder='{"username": "myuser", "password": "mypassword"}'
|
||||
className="mt-1 font-mono text-xs min-h-[100px]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💾</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
||||
<Select
|
||||
value={configData.targetConnectionId?.toString() || ""}
|
||||
onValueChange={(val) => {
|
||||
const connId = parseInt(val);
|
||||
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
|
||||
loadTables(connId); // 테이블 목록 로드
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map(conn => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name || conn.name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Select
|
||||
value={configData.targetTable || ""}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
|
||||
disabled={!configData.targetConnectionId}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetTables.length > 0 ? (
|
||||
targetTables.map(table => (
|
||||
<SelectItem key={table} value={table}>{table}</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2 text-xs text-center text-slate-400">테이블 없음</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -361,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
|
||||
@@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
|
||||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user