Files
sam-manage/resources/views/esign/sign/auth.blade.php
김보곤 3281788536 feat:E-Sign 전자계약 서명 솔루션 MNG 프론트엔드 구현
- 컨트롤러 2개 (EsignController, EsignPublicController)
- 뷰 8개 (dashboard, create, detail, fields, send, sign/auth, sign/sign, sign/done)
- React 하이브리드 방식 (기존 Finance 패턴)
- 라우트 추가 (인증 esign/* + 공개 esign/sign/*)
- PDF.js 기반 서명 위치 설정
- signature_pad 기반 전자서명 입력
- OTP 본인인증 플로우

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 07:02:48 +09:00

165 lines
7.7 KiB
PHP

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>전자계약 본인인증 - SAM E-Sign</title>
@vite(['resources/css/app.css'])
</head>
<body class="bg-gray-50 min-h-screen">
<div id="esign-auth-root" data-token="{{ $token }}"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect, useCallback } = React;
const TOKEN = document.getElementById('esign-auth-root')?.dataset.token;
const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}';
const API_KEY = '{{ config("services.api.key", "") }}';
const App = () => {
const [contract, setContract] = useState(null);
const [signer, setSigner] = useState(null);
const [step, setStep] = useState('loading'); // loading, info, otp
const [otp, setOtp] = useState('');
const [otpSending, setOtpSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState('');
const fetchContract = useCallback(async () => {
try {
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, {
headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY },
});
const json = await res.json();
if (json.success) {
setContract(json.data.contract);
setSigner(json.data.signer);
setStep('info');
} else {
setError(json.message || '계약 정보를 불러올 수 없습니다.');
}
} catch (e) {
setError('서버에 연결할 수 없습니다.');
}
}, []);
useEffect(() => { fetchContract(); }, [fetchContract]);
const sendOtp = async () => {
setOtpSending(true);
setError('');
try {
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/send`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
});
const json = await res.json();
if (json.success) {
setStep('otp');
} else {
setError(json.message || 'OTP 발송에 실패했습니다.');
}
} catch (e) { setError('서버 오류'); }
setOtpSending(false);
};
const verifyOtp = async (e) => {
e.preventDefault();
setVerifying(true);
setError('');
try {
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/verify`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify({ otp_code: otp }),
});
const json = await res.json();
if (json.success) {
sessionStorage.setItem('esign_session_token', json.data.sign_session_token);
location.href = `/esign/sign/${TOKEN}/sign`;
} else {
setError(json.message || '인증 코드가 올바르지 않습니다.');
}
} catch (e) { setError('서버 오류'); }
setVerifying(false);
};
if (step === 'loading') return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-400">{error || '로딩 중...'}</p>
</div>
);
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="w-full max-w-md">
{/* 헤더 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">SAM E-Sign</h1>
<p className="text-gray-500 mt-1">전자계약 서명</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4 text-sm">{error}</div>}
{step === 'info' && (
<>
<h2 className="text-lg font-semibold mb-4">계약 정보 확인</h2>
<div className="space-y-3 mb-6">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-1">계약 제목</p>
<p className="font-medium">{contract?.title}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-1">서명자</p>
<p className="font-medium">{signer?.name} ({signer?.email})</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-1">서명 기한</p>
<p className="font-medium text-red-600">{contract?.expires_at?.slice(0,10) || '-'}</p>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다.
</p>
<button onClick={sendOtp} disabled={otpSending}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
{otpSending ? '발송 중...' : '인증 코드 발송'}
</button>
</>
)}
{step === 'otp' && (
<>
<h2 className="text-lg font-semibold mb-4">인증 코드 입력</h2>
<p className="text-sm text-gray-600 mb-4">
<strong>{signer?.email}</strong> 발송된 6자리 인증 코드를 입력해 주세요.
</p>
<form onSubmit={verifyOtp} className="space-y-4">
<input type="text" value={otp} onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000" maxLength={6} autoFocus
className="w-full text-center text-3xl tracking-[0.5em] border-2 rounded-lg px-4 py-4 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<button type="submit" disabled={verifying || otp.length !== 6}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
{verifying ? '확인 중...' : '인증 확인'}
</button>
</form>
<button onClick={sendOtp} className="w-full mt-3 py-2 text-sm text-blue-600 hover:text-blue-800">
인증 코드 재발송
</button>
</>
)}
</div>
</div>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('esign-auth-root'));
</script>
</body>
</html>