import { useState, useRef, useEffect } from "react"; import { toast } from "sonner"; import type { ServiceDetail } from "../types"; import { registerFcm, deleteFcm, registerApns, deleteApns, } from "@/api/service.api"; // 파일을 텍스트로 읽기 (FCM JSON, APNs p8) function readFileAsText(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(new Error("파일 읽기 실패")); reader.readAsText(file); }); } // 파일을 Base64로 읽기 (APNs p12) function readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; // "data:application/...;base64," 접두사 제거 const base64 = dataUrl.split(",")[1] ?? ""; resolve(base64); }; reader.onerror = () => reject(new Error("파일 읽기 실패")); reader.readAsDataURL(file); }); } // Apple 로고 SVG const AppleLogo = ({ className }: { className?: string }) => ( ); // 인증 상태 배지 function CredentialStatusBadge({ status, reason, }: { status: "ok" | "warn" | "error" | null; reason: string | null; }) { if (!status || status === "ok") { return (
인증됨
); } if (status === "warn") { return (
조치 필요 {reason && (

{reason}

)}
); } // error return (
미인증 {reason && (

{reason}

)}
); } // 플랫폼 아이템 메뉴 function PlatformMenu({ onEdit, onDelete, }: { onEdit: () => void; onDelete: () => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener("click", handler); return () => document.removeEventListener("click", handler); }, []); return (
{open && (
)}
); } interface PlatformManagementProps { service: ServiceDetail; onRefresh: () => void; } export default function PlatformManagement({ service, onRefresh, }: PlatformManagementProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null); const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8"); const [selectedFile, setSelectedFile] = useState(null); const [showPassword, setShowPassword] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [addTab, setAddTab] = useState<"android" | "ios">("android"); const [addIosAuthType, setAddIosAuthType] = useState<"p8" | "p12">("p8"); const [addFile, setAddFile] = useState(null); const [addShowPassword, setAddShowPassword] = useState(false); const [submitting, setSubmitting] = useState(false); // 추가 모달 폼 상태 const [addBundleId, setAddBundleId] = useState(""); const [addKeyId, setAddKeyId] = useState(""); const [addTeamId, setAddTeamId] = useState(""); const [addCertPassword, setAddCertPassword] = useState(""); // 수정 모달 폼 상태 const [editBundleId, setEditBundleId] = useState(""); const [editKeyId, setEditKeyId] = useState(""); const [editTeamId, setEditTeamId] = useState(""); const [editCertPassword, setEditCertPassword] = useState(""); const androidVisible = !!service.platforms?.android?.registered; const iosVisible = !!service.platforms?.ios?.registered; const allRegistered = androidVisible && iosVisible; const noneRegistered = !androidVisible && !iosVisible; // 추가 모달 폼 초기화 const resetAddForm = () => { setAddFile(null); setAddShowPassword(false); setAddBundleId(""); setAddKeyId(""); setAddTeamId(""); setAddCertPassword(""); }; // 수정 모달 폼 초기화 const resetEditForm = () => { setSelectedFile(null); setShowPassword(false); setEditBundleId(""); setEditKeyId(""); setEditTeamId(""); setEditCertPassword(""); }; // 플랫폼 추가 핸들러 const handleAdd = async () => { if (!addFile) { toast.error("인증서 파일을 업로드해주세요."); return; } const isAndroidTab = addTab === "android"; setSubmitting(true); try { if (isAndroidTab) { // Android: FCM JSON const text = await readFileAsText(addFile); await registerFcm(service.serviceCode, { serviceAccountJson: text }); } else if (addIosAuthType === "p8") { // iOS p8 if (!addBundleId.trim()) { toast.error("Bundle ID를 입력해주세요."); setSubmitting(false); return; } if (!addKeyId.trim() || addKeyId.trim().length !== 10) { toast.error("Key ID는 정확히 10자여야 합니다."); setSubmitting(false); return; } if (!addTeamId.trim() || addTeamId.trim().length !== 10) { toast.error("Team ID는 정확히 10자여야 합니다."); setSubmitting(false); return; } const privateKey = await readFileAsText(addFile); await registerApns(service.serviceCode, { authType: "p8", bundleId: addBundleId.trim(), keyId: addKeyId.trim(), teamId: addTeamId.trim(), privateKey, }); } else { // iOS p12 if (!addBundleId.trim()) { toast.error("Bundle ID를 입력해주세요."); setSubmitting(false); return; } const certificateBase64 = await readFileAsBase64(addFile); await registerApns(service.serviceCode, { authType: "p12", bundleId: addBundleId.trim(), certificateBase64, certPassword: addCertPassword, }); } const name = isAndroidTab ? "Android" : "iOS"; toast.success(`${name} 플랫폼이 추가되었습니다.`); setShowAddModal(false); resetAddForm(); onRefresh(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "플랫폼 추가에 실패했습니다."; toast.error(msg); } finally { setSubmitting(false); } }; // 인증서 수정(덮어쓰기) 핸들러 const handleEdit = async () => { if (!editTarget || !selectedFile) { toast.error("인증서 파일을 업로드해주세요."); return; } const isAndroid = editTarget === "android"; setSubmitting(true); try { if (isAndroid) { const text = await readFileAsText(selectedFile); await registerFcm(service.serviceCode, { serviceAccountJson: text }); } else if (iosAuthType === "p8") { if (!editBundleId.trim()) { toast.error("Bundle ID를 입력해주세요."); setSubmitting(false); return; } if (!editKeyId.trim() || editKeyId.trim().length !== 10) { toast.error("Key ID는 정확히 10자여야 합니다."); setSubmitting(false); return; } if (!editTeamId.trim() || editTeamId.trim().length !== 10) { toast.error("Team ID는 정확히 10자여야 합니다."); setSubmitting(false); return; } const privateKey = await readFileAsText(selectedFile); await registerApns(service.serviceCode, { authType: "p8", bundleId: editBundleId.trim(), keyId: editKeyId.trim(), teamId: editTeamId.trim(), privateKey, }); } else { if (!editBundleId.trim()) { toast.error("Bundle ID를 입력해주세요."); setSubmitting(false); return; } const certificateBase64 = await readFileAsBase64(selectedFile); await registerApns(service.serviceCode, { authType: "p12", bundleId: editBundleId.trim(), certificateBase64, certPassword: editCertPassword, }); } toast.success("인증서가 저장되었습니다."); setEditTarget(null); resetEditForm(); onRefresh(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "인증서 저장에 실패했습니다."; toast.error(msg); } finally { setSubmitting(false); } }; // 플랫폼 삭제 핸들러 const handleDelete = async () => { if (!deleteTarget) return; setSubmitting(true); try { if (deleteTarget === "Android") { await deleteFcm(service.serviceCode); } else { await deleteApns(service.serviceCode); } toast.success(`${deleteTarget} 플랫폼이 삭제되었습니다.`); setDeleteTarget(null); onRefresh(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "플랫폼 삭제에 실패했습니다."; toast.error(msg); } finally { setSubmitting(false); } }; return ( <>
{/* 헤더 */}

플랫폼 관리

{/* Android */} {androidVisible && (
android

Android

Google FCM을 통한 Android 기기 관리

{ setEditTarget("android"); resetEditForm(); }} onDelete={() => setDeleteTarget("Android")} />
)} {/* iOS */} {iosVisible && (

iOS

Apple APNs를 통한 iOS 기기 관리

{ setEditTarget("ios"); resetEditForm(); // 기존 인증 방식이 있으면 프리셋 if (service.apnsAuthType) setIosAuthType(service.apnsAuthType); if (service.apnsBundleId) setEditBundleId(service.apnsBundleId); if (service.apnsKeyId) setEditKeyId(service.apnsKeyId); if (service.apnsTeamId) setEditTeamId(service.apnsTeamId); }} onDelete={() => setDeleteTarget("iOS")} />
)} {/* 빈 상태 */} {noneRegistered && (
devices

등록된 플랫폼이 없습니다.

플랫폼 추가 버튼을 눌러 플랫폼을 등록하세요.

)}
{/* 플랫폼 추가 모달 (탭 방식) */} {showAddModal && (() => { const androidAvail = !androidVisible; const iosAvail = !iosVisible; const isAndroidTab = addTab === "android"; const fileAccept = isAndroidTab ? ".json" : addIosAuthType === "p8" ? ".p8" : ".p12"; const fileHint = isAndroidTab ? ".json 파일만 업로드 가능합니다" : addIosAuthType === "p8" ? ".p8 파일만 업로드 가능합니다" : ".p12 파일만 업로드 가능합니다"; return (
!submitting && setShowAddModal(false)} />
{/* 헤더 */}
add_circle

플랫폼 추가

추가할 플랫폼을 선택하고 인증서를 업로드하세요.

{/* 탭 */}
{/* iOS 인증 방식 선택 */} {!isAndroidTab && (
)} {/* 파일 업로드 */}
{!addFile ? ( ) : (
description

{addFile.name}

{(addFile.size / 1024).toFixed(1)} KB

)}
{/* iOS Bundle ID */} {!isAndroidTab && (
setAddBundleId(e.target.value)} placeholder="예: com.example.app" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
)} {/* iOS P8: Key ID + Team ID */} {!isAndroidTab && addIosAuthType === "p8" && (
setAddKeyId(e.target.value)} placeholder="예: ABC123DEFG" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
setAddTeamId(e.target.value)} placeholder="예: 9ABCDEFGH1" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
info Apple Developer 포털에서 확인할 수 있습니다.
)} {/* iOS P12: 비밀번호 */} {!isAndroidTab && addIosAuthType === "p12" && (
setAddCertPassword(e.target.value)} placeholder="P12 인증서 비밀번호를 입력하세요" disabled={submitting} className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
)} {/* 하단 버튼 */}
); })()} {/* 삭제 확인 모달 */} {deleteTarget && (
!submitting && setDeleteTarget(null)} />
warning

플랫폼 삭제

{deleteTarget} 플랫폼을 삭제하시겠습니까?

info 삭제 후 해당 플랫폼의 인증서 및 설정이 모두 제거됩니다.
)} {/* 인증서 수정 모달 */} {editTarget && (() => { const cred = editTarget === "android" ? service.platforms?.android : service.platforms?.ios; const isAndroid = editTarget === "android"; const statusLabel = cred?.credentialStatus === "error" ? "미인증 상태" : cred?.credentialStatus === "warn" ? "조치 필요" : "인증됨"; const statusDesc = cred?.credentialStatus === "error" ? "인증서가 등록되지 않았습니다. 인증서를 업로드해주세요." : cred?.statusReason ?? ""; const statusColor = cred?.credentialStatus === "error" ? "red" : cred?.credentialStatus === "warn" ? "amber" : "green"; return (
!submitting && setEditTarget(null)} />
{/* 헤더 */}
{isAndroid ? ( android ) : ( )}

인증서 등록/수정

{isAndroid ? "Android — Service Account JSON" : `iOS — ${iosAuthType === "p8" ? "APNs Auth Key (.p8)" : "Certificate (.p12)"}`}

{/* 현재 상태 */}
{cred?.credentialStatus === "error" ? "error" : cred?.credentialStatus === "warn" ? "warning" : "check_circle"} {statusLabel}
{statusDesc && (

{statusDesc}

)}
{/* iOS 인증 방식 선택 */} {!isAndroid && (
)} {/* 파일 업로드 */}
{!selectedFile ? ( ) : (
description

{selectedFile.name}

{(selectedFile.size / 1024).toFixed(1)} KB

)}
{/* iOS Bundle ID */} {!isAndroid && (
setEditBundleId(e.target.value)} placeholder="예: com.example.app" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
)} {/* iOS P8: Key ID + Team ID */} {!isAndroid && iosAuthType === "p8" && (
setEditKeyId(e.target.value)} placeholder="예: ABC123DEFG" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
setEditTeamId(e.target.value)} placeholder="예: 9ABCDEFGH1" disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
info Apple Developer 포털에서 확인할 수 있습니다.
)} {/* iOS P12: 비밀번호 */} {!isAndroid && iosAuthType === "p12" && (
setEditCertPassword(e.target.value)} placeholder="P12 인증서 비밀번호를 입력하세요" disabled={submitting} className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
)} {/* 하단 버튼 */}
); })()} ); }