- 컨트롤러 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>
165 lines
7.7 KiB
PHP
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>
|