feat: xkeypad 보안 키패드 통합 및 비밀번호 변경 기능 구현

- xkeypad 보안 키패드 라이브러리 추가
- 비밀번호 변경 페이지에 보안 키패드 적용
- RSA 암호화 기능 통합
- route 설정 및 Sentry 설정 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-10-21 14:59:07 +09:00
parent ab5bea6aeb
commit 1648a30844
41 changed files with 3426 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { PATHS } from '@/shared/constants/paths';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { HeaderType } from '@/entities/common/model/types';
@@ -11,6 +11,7 @@ import {
import { useUserChangeCancelPasswordMutation } from '@/entities/user/api/use-user-change-cancel-password-mutation';
import { snackBar } from '@/shared/lib/toast';
import { useStore } from '@/shared/model/store';
import { XKeypad, XKeypadManager, createPasswordKeypad } from '@/utils/xkeypad';
export const PasswordModifyCancelPasswordPage = () => {
const { navigate } = useNavigate();
@@ -20,13 +21,30 @@ export const PasswordModifyCancelPasswordPage = () => {
const [mid, setMid] = useState<string>(userMid);
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [isKeypadLoaded, setIsKeypadLoaded] = useState(false);
// Input refs for xkeypad
const passwordInputRef = useRef<HTMLInputElement>(null);
const confirmPasswordInputRef = useRef<HTMLInputElement>(null);
// XKeypad instances
const passwordKeypadRef = useRef<XKeypad | null>(null);
const confirmPasswordKeypadRef = useRef<XKeypad | null>(null);
// RSA Keys (실제 프로덕션에서는 서버에서 받아와야 함)
const RSA_MODULUS = "C4F7B39E2E93DB19C016C7A0C1C05B028A1D57CB9B91E13F5B7353F8FB5AC6CE6BE31ABEB8E8F7AD18B90C08F4EBC011A6A8FCE614EA879ED5B96296B969CE92923BC9BAD6FD87F00E08F529F93010EA77E40937BDAC1C866E79ACE2F2822A3ECD982F90532D5301CF90D9BF89E953A0593AB6C5F31E99B690DD582FB85F85A9";
const RSA_EXPONENT = "10001";
const changeCancelPasswordMutation = useUserChangeCancelPasswordMutation({
onSuccess: () => {
snackBar('비밀번호가 성공적으로 변경되었습니다.');
// Clear form
// Clear form and keypads
setPassword('');
setConfirmPassword('');
if (passwordKeypadRef.current) passwordKeypadRef.current.clear();
if (confirmPasswordKeypadRef.current) confirmPasswordKeypadRef.current.clear();
if (passwordInputRef.current) passwordInputRef.current.value = '';
if (confirmPasswordInputRef.current) confirmPasswordInputRef.current.value = '';
// Navigate back
navigate(PATHS.account.password.manage);
},
@@ -42,6 +60,39 @@ export const PasswordModifyCancelPasswordPage = () => {
{ value: 'nictest02m', label: 'nictest02m' },
];
// Initialize XKeypad
useEffect(() => {
const initializeKeypad = async () => {
try {
const manager = XKeypadManager.getInstance({
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
await manager.loadScripts();
// RSA 키 설정을 명시적으로 다시 한번 수행
manager.setRSAPublicKey(RSA_MODULUS, RSA_EXPONENT);
setIsKeypadLoaded(true);
} catch (error) {
console.error('Failed to load XKeypad:', error);
}
};
initializeKeypad();
return () => {
// Cleanup keypads on unmount
if (passwordKeypadRef.current) {
passwordKeypadRef.current.destroy();
}
if (confirmPasswordKeypadRef.current) {
confirmPasswordKeypadRef.current.destroy();
}
};
}, []);
useSetHeaderTitle('거래취소 비밀번호 변경');
useSetHeaderType(HeaderType.LeftArrow);
useSetFooterMode(false);
@@ -58,11 +109,97 @@ export const PasswordModifyCancelPasswordPage = () => {
);
};
// Handle password keypad
const handlePasswordKeypad = async () => {
if (!passwordInputRef.current || !isKeypadLoaded) return;
// Close other keypad if open
if (confirmPasswordKeypadRef.current) {
confirmPasswordKeypadRef.current.close();
}
// Create or initialize password keypad
if (!passwordKeypadRef.current) {
passwordKeypadRef.current = createPasswordKeypad(passwordInputRef.current, {
keyType: 'qwertysmart',
viewType: 'half',
maxInputSize: 16,
useOverlay: true,
useModal: false,
hasPressEffect: true,
isE2E: false, // E2E 모드 비활성화
onInputChange: (length: number) => {
// Update password state as typing
if (passwordKeypadRef.current) {
const plainText = passwordKeypadRef.current.getPlainText();
console.log('passwordKeypadRef:', plainText, passwordInputRef.current?.value);
setPassword(plainText);
}
},
onKeypadClose: () => {
// Final update when keypad closes
if (passwordKeypadRef.current) {
const plainText = passwordKeypadRef.current.getPlainText();
setPassword(plainText);
}
}
});
}
const result = await passwordKeypadRef.current.initialize(passwordInputRef.current);
if (result !== 0) {
console.error('Failed to initialize password keypad');
}
};
// Handle confirm password keypad
const handleConfirmPasswordKeypad = async () => {
if (!confirmPasswordInputRef.current || !isKeypadLoaded) return;
// Close other keypad if open
if (passwordKeypadRef.current) {
passwordKeypadRef.current.close();
}
// Create or initialize confirm password keypad
if (!confirmPasswordKeypadRef.current) {
confirmPasswordKeypadRef.current = createPasswordKeypad(confirmPasswordInputRef.current, {
keyType: 'qwertysmart',
viewType: 'half',
maxInputSize: 16,
useOverlay: true,
useModal: false,
hasPressEffect: true,
isE2E: false, // E2E 모드 비활성화
onInputChange: (length: number) => {
// Update confirm password state as typing
if (confirmPasswordKeypadRef.current) {
const plainText = confirmPasswordKeypadRef.current.getPlainText();
console.log('confirmPasswordKeypadRef:', plainText, confirmPasswordInputRef.current?.value);
setConfirmPassword(plainText);
}
},
onKeypadClose: () => {
// Final update when keypad closes
if (confirmPasswordKeypadRef.current) {
const plainText = confirmPasswordKeypadRef.current.getPlainText();
setConfirmPassword(plainText);
}
}
});
}
const result = await confirmPasswordKeypadRef.current.initialize(confirmPasswordInputRef.current);
if (result !== 0) {
console.error('Failed to initialize confirm password keypad');
}
};
// 저장 버튼 클릭 핸들러
const handleSave = () => {
if (!isFormValid()) return;
// TODO: Validate current password before submitting
// 평문 비밀번호 사용 (E2E 모드가 꺼져있으므로)
changeCancelPasswordMutation.mutate({
mid,
password: password
@@ -96,21 +233,27 @@ export const PasswordModifyCancelPasswordPage = () => {
<div className="ua-row">
<div className="ua-label"> <span className="red">*</span></div>
<input
ref={passwordInputRef}
className={`wid-100 ${confirmPassword && password !== confirmPassword ? 'error' : ''}`}
type="password"
placeholder=""
placeholder="클릭하여 비밀번호 입력"
value={password}
onChange={(e) => setPassword(e.target.value)}
onClick={handlePasswordKeypad}
readOnly
style={{ cursor: 'pointer' }}
/>
</div>
<div className="ua-row">
<div className="ua-label"> <span className="red">*</span></div>
<input
ref={confirmPasswordInputRef}
className={`wid-100 ${confirmPassword && password !== confirmPassword ? 'error' : ''}`}
type="password"
placeholder=""
placeholder="클릭하여 비밀번호 재입력"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onClick={handleConfirmPasswordKeypad}
readOnly
style={{ cursor: 'pointer' }}
/>
</div>
{confirmPassword && password !== confirmPassword && (
@@ -118,7 +261,7 @@ export const PasswordModifyCancelPasswordPage = () => {
)}
</div>
<div className="apply-row bottom-padding">
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
type="button"

View File

@@ -104,7 +104,7 @@ export const PasswordModifyLoginPasswordPage = () => {
)}
</div>
<div className="apply-row bottom-padding">
<div className="apply-row">
<button
className="btn-50 btn-blue flex-1"
type="button"

View File

@@ -0,0 +1,298 @@
/* XKeypad Demo Styles */
.xkeypad-demo-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
.xkeypad-demo-container h1 {
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
margin-bottom: 30px;
}
.xkeypad-demo-container h2 {
color: #555;
margin-top: 30px;
margin-bottom: 20px;
font-size: 1.5rem;
}
.xkeypad-demo-container h3 {
color: #666;
margin-top: 20px;
margin-bottom: 15px;
font-size: 1.2rem;
}
/* Configuration Section */
.config-section {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.config-group {
margin-bottom: 20px;
}
.config-group label {
display: block;
margin-bottom: 8px;
color: #333;
}
.config-group strong {
display: block;
margin-bottom: 10px;
color: #444;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-group label {
display: flex;
align-items: center;
cursor: pointer;
margin: 0;
}
.radio-group input[type="radio"] {
margin-right: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 8px;
}
/* Input Section */
.input-section {
background: #fff;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 30px;
}
.input-group {
margin-bottom: 30px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.input-group label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: #333;
}
.input-group input[type="password"],
.input-group input[type="text"] {
width: 100%;
max-width: 400px;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: border-color 0.3s;
}
.input-group input[type="password"]:hover,
.input-group input[type="text"]:hover {
border-color: #4CAF50;
}
.input-group input[type="password"]:focus,
.input-group input[type="text"]:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
/* Buttons */
.submit-btn {
margin-top: 10px;
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.submit-btn:hover {
background: #45a049;
}
.submit-btn:active {
transform: translateY(1px);
}
.control-buttons {
display: flex;
gap: 15px;
justify-content: center;
margin: 30px 0;
}
.control-btn {
padding: 12px 30px;
background: #2196F3;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.control-btn:hover {
background: #1976D2;
}
.control-btn.clear-btn {
background: #f44336;
}
.control-btn.clear-btn:hover {
background: #d32f2f;
}
/* Result Box */
.result-box {
margin-top: 15px;
padding: 15px;
background: #e8f5e9;
border: 1px solid #4CAF50;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.result-box h4 {
margin-top: 0;
margin-bottom: 10px;
color: #2e7d32;
}
.result-box p {
margin: 5px 0;
word-break: break-all;
color: #333;
}
.result-box .encrypted {
color: #d32f2f;
font-size: 12px;
}
/* Info Section */
.info-section {
background: #fff3e0;
padding: 20px;
border-radius: 8px;
border: 1px solid #ffb74d;
}
.info-section ul {
margin: 0;
padding-left: 25px;
}
.info-section li {
margin: 10px 0;
color: #555;
line-height: 1.6;
}
/* Keypad Container Styles */
[id^="xk-pad-"] {
margin-top: 10px;
position: relative;
min-height: 50px;
}
/* Responsive Design */
@media (max-width: 768px) {
.xkeypad-demo-container {
padding: 15px;
}
.radio-group {
flex-direction: column;
gap: 10px;
}
.input-group input[type="password"],
.input-group input[type="text"] {
max-width: 100%;
}
.control-buttons {
flex-direction: column;
}
.control-btn {
width: 100%;
}
}
/* Loading State */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error Message */
.error-message {
padding: 10px;
background: #ffebee;
border: 1px solid #ef5350;
border-radius: 4px;
color: #c62828;
margin-top: 10px;
}
/* Success Message */
.success-message {
padding: 10px;
background: #e8f5e9;
border: 1px solid #66bb6a;
border-radius: 4px;
color: #2e7d32;
margin-top: 10px;
}

View File

@@ -0,0 +1,561 @@
import React, { useState, useRef, useEffect } from 'react';
import {
XKeypad,
XKeypadManager,
createPasswordKeypad,
createPinKeypad,
createCardKeypad,
type XKeypadOptions,
type XKeypadResult,
type KeyType,
type ViewType,
type NumberKeyRowCount
} from '../../utils/xkeypad';
import './xkeypad-styles.css';
export const XkeypadPage: React.FC = () => {
// State for keypad options
const [keyType, setKeyType] = useState<KeyType>('qwertysmart');
const [viewType, setViewType] = useState<ViewType>('half');
const [numberKeyRowCount, setNumberKeyRowCount] = useState<NumberKeyRowCount>(3);
const [autoKeyResize, setAutoKeyResize] = useState(false);
const [isE2E, setIsE2E] = useState(false);
const [onlyMobile, setOnlyMobile] = useState(false);
const [hasPressEffect, setHasPressEffect] = useState(true);
const [useModal, setUseModal] = useState(false);
const [useOverlay, setUseOverlay] = useState(true); // 오버레이 기본 활성화
// State for results
const [passwordResult, setPasswordResult] = useState<XKeypadResult | null>(null);
const [pinResult, setPinResult] = useState<XKeypadResult | null>(null);
const [cardResult, setCardResult] = useState<XKeypadResult | null>(null);
// State for scripts loaded
const [scriptsLoaded, setScriptsLoaded] = useState(false);
// Input refs
const passwordInputRef = useRef<HTMLInputElement>(null);
const pinInputRef = useRef<HTMLInputElement>(null);
const cardNumberInputRef = useRef<HTMLInputElement>(null);
// XKeypad instances
const passwordKeypadRef = useRef<XKeypad | null>(null);
const pinKeypadRef = useRef<XKeypad | null>(null);
const cardKeypadRef = useRef<XKeypad | null>(null);
// RSA Keys
const RSA_MODULUS = "C4F7B39E2E93DB19C016C7A0C1C05B028A1D57CB9B91E13F5B7353F8FB5AC6CE6BE31ABEB8E8F7AD18B90C08F4EBC011A6A8FCE614EA879ED5B96296B969CE92923BC9BAD6FD87F00E08F529F93010EA77E40937BDAC1C866E79ACE2F2822A3ECD982F90532D5301CF90D9BF89E953A0593AB6C5F31E99B690DD582FB85F85A9";
const RSA_EXPONENT = "10001";
// Load scripts and initialize manager
useEffect(() => {
const initializeKeypadManager = async () => {
try {
const manager = XKeypadManager.getInstance({
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
await manager.loadScripts();
setScriptsLoaded(true);
} catch (error) {
console.error('Failed to load XKeypad scripts:', error);
}
};
initializeKeypadManager();
return () => {
// Cleanup on unmount
if (passwordKeypadRef.current) {
passwordKeypadRef.current.destroy();
}
if (pinKeypadRef.current) {
pinKeypadRef.current.destroy();
}
if (cardKeypadRef.current) {
cardKeypadRef.current.destroy();
}
};
}, []);
// Close all keypads
const closeAllKeypads = () => {
if (passwordKeypadRef.current) {
passwordKeypadRef.current.close();
}
if (pinKeypadRef.current) {
pinKeypadRef.current.close();
}
if (cardKeypadRef.current) {
cardKeypadRef.current.close();
}
};
// Handle password keypad
const handlePasswordKeypad = async () => {
if (!scriptsLoaded || !passwordInputRef.current) return;
// Close other keypads
if (pinKeypadRef.current) pinKeypadRef.current.close();
if (cardKeypadRef.current) cardKeypadRef.current.close();
// Create or get existing keypad
if (!passwordKeypadRef.current) {
passwordKeypadRef.current = createPasswordKeypad(passwordInputRef.current, {
keyType: keyType,
viewType: viewType,
numberKeyRowCount: numberKeyRowCount,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('Password input length:', newLength);
},
onKeypadClose: () => {
console.log('Password keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
} else {
// Update options if they changed
passwordKeypadRef.current = new XKeypad('password', {
keyType: keyType,
viewType: viewType,
numberKeyRowCount: numberKeyRowCount,
maxInputSize: 16,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('Password input length:', newLength);
},
onKeypadClose: () => {
console.log('Password keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
}
const result = await passwordKeypadRef.current.initialize(passwordInputRef.current);
if (result === 0) {
console.log('Password keypad initialized successfully');
} else if (result === -1) {
alert('지원하지 않는 기기입니다.');
}
};
// Handle PIN keypad
const handlePinKeypad = async () => {
if (!scriptsLoaded || !pinInputRef.current) return;
// Close other keypads
if (passwordKeypadRef.current) passwordKeypadRef.current.close();
if (cardKeypadRef.current) cardKeypadRef.current.close();
// Create or get existing keypad
if (!pinKeypadRef.current) {
pinKeypadRef.current = createPinKeypad(pinInputRef.current, {
viewType: viewType,
numberKeyRowCount: numberKeyRowCount,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('PIN input length:', newLength);
if (newLength === 6 && pinKeypadRef.current && pinKeypadRef.current.isOpen()) {
pinKeypadRef.current.close();
}
},
onKeypadClose: () => {
console.log('PIN keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
} else {
// Update options if they changed
pinKeypadRef.current = new XKeypad('pin', {
keyType: 'number',
viewType: viewType,
numberKeyRowCount: numberKeyRowCount,
maxInputSize: 6,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('PIN input length:', newLength);
if (newLength === 6 && pinKeypadRef.current && pinKeypadRef.current.isOpen()) {
pinKeypadRef.current.close();
}
},
onKeypadClose: () => {
console.log('PIN keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
}
const result = await pinKeypadRef.current.initialize(pinInputRef.current);
if (result === 0) {
console.log('PIN keypad initialized successfully');
}
};
// Handle card keypad
const handleCardKeypad = async () => {
if (!scriptsLoaded || !cardNumberInputRef.current) return;
// Close other keypads
if (passwordKeypadRef.current) passwordKeypadRef.current.close();
if (pinKeypadRef.current) pinKeypadRef.current.close();
// Create or get existing keypad
if (!cardKeypadRef.current) {
cardKeypadRef.current = createCardKeypad(cardNumberInputRef.current, {
viewType: viewType,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('Card number input length:', newLength);
if (newLength === 16 && cardKeypadRef.current && cardKeypadRef.current.isOpen()) {
cardKeypadRef.current.close();
}
},
onKeypadClose: () => {
console.log('Card keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
} else {
// Update options if they changed
cardKeypadRef.current = new XKeypad('card', {
keyType: 'number',
viewType: viewType,
numberKeyRowCount: 4,
maxInputSize: 16,
autoKeyResize: autoKeyResize,
isE2E: isE2E,
onlyMobile: onlyMobile,
hasPressEffect: hasPressEffect,
useModal: useModal,
useOverlay: useOverlay,
onInputChange: (newLength: number) => {
console.log('Card number input length:', newLength);
if (newLength === 16 && cardKeypadRef.current && cardKeypadRef.current.isOpen()) {
cardKeypadRef.current.close();
}
},
onKeypadClose: () => {
console.log('Card keypad closed');
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
}
const result = await cardKeypadRef.current.initialize(cardNumberInputRef.current);
if (result === 0) {
console.log('Card keypad initialized successfully');
}
};
// Handle submit functions
const handlePasswordSubmit = () => {
if (!passwordKeypadRef.current) return;
const result = passwordKeypadRef.current.getValue();
if (result) {
setPasswordResult(result);
console.log('Password Result:', result);
alert(`Password submitted:\n${JSON.stringify(result, null, 2)}`);
}
};
const handlePinSubmit = () => {
if (!pinKeypadRef.current) return;
const result = pinKeypadRef.current.getValue();
if (result) {
setPinResult(result);
console.log('PIN Result:', result);
alert(`PIN submitted:\n${JSON.stringify(result, null, 2)}`);
}
};
const handleCardSubmit = () => {
if (!cardKeypadRef.current) return;
const result = cardKeypadRef.current.getValue();
if (result) {
setCardResult(result);
console.log('Card Result:', result);
alert(`Card Number submitted:\n${JSON.stringify(result, null, 2)}`);
}
};
const clearAll = () => {
if (passwordKeypadRef.current) {
passwordKeypadRef.current.clear();
}
if (pinKeypadRef.current) {
pinKeypadRef.current.clear();
}
if (cardKeypadRef.current) {
cardKeypadRef.current.clear();
}
if (passwordInputRef.current) passwordInputRef.current.value = '';
if (pinInputRef.current) pinInputRef.current.value = '';
if (cardNumberInputRef.current) cardNumberInputRef.current.value = '';
setPasswordResult(null);
setPinResult(null);
setCardResult(null);
};
if (!scriptsLoaded) {
return <div>Loading XKeypad scripts...</div>;
}
return (
<div className="xkeypad-demo-container">
<h1>XecureKeypad Demo (Direct JS Integration)</h1>
<div className="demo-section">
<h2>Keypad Options</h2>
<div className="option-group">
<label>
<strong>Key Type:</strong>
<div>
<label>
<input
type="radio"
value="qwertysmart"
checked={keyType === 'qwertysmart'}
onChange={(e) => setKeyType(e.target.value as KeyType)}
/>
Qwerty Smart
</label>
<label>
<input
type="radio"
value="number"
checked={keyType === 'number'}
onChange={(e) => setKeyType(e.target.value as KeyType)}
/>
Number
</label>
</div>
</label>
</div>
<div className="option-group">
<label>
<strong>View Type:</strong>
<div>
<label>
<input
type="radio"
value="half"
checked={viewType === 'half'}
onChange={(e) => setViewType(e.target.value as ViewType)}
disabled={useModal}
/>
Half (Bottom Fixed)
</label>
<label>
<input
type="radio"
value="normal"
checked={viewType === 'normal'}
onChange={(e) => setViewType(e.target.value as ViewType)}
disabled={useModal}
/>
Normal (Below Input)
</label>
</div>
</label>
</div>
<div className="option-group">
<label>
<strong>Number Key Row Count:</strong>
<div>
{[2, 3, 4].map(count => (
<label key={count}>
<input
type="radio"
value={count}
checked={numberKeyRowCount === count}
onChange={(e) => setNumberKeyRowCount(Number(e.target.value) as NumberKeyRowCount)}
/>
{count} rows
</label>
))}
</div>
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={autoKeyResize}
onChange={(e) => setAutoKeyResize(e.target.checked)}
/>
Auto Key Resize
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={isE2E}
onChange={(e) => setIsE2E(e.target.checked)}
/>
E2E Communication
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={onlyMobile}
onChange={(e) => setOnlyMobile(e.target.checked)}
/>
Only Mobile
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={hasPressEffect}
onChange={(e) => setHasPressEffect(e.target.checked)}
/>
Has Press Effect
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={useModal}
onChange={(e) => setUseModal(e.target.checked)}
/>
Use Modal
</label>
</div>
<div className="option-group">
<label>
<input
type="checkbox"
checked={useOverlay}
onChange={(e) => setUseOverlay(e.target.checked)}
disabled={useModal}
/>
Use Overlay (Background)
</label>
</div>
</div>
<div className="demo-section">
<h2>Input Fields</h2>
<div className="input-group">
<label>Password:</label>
<div className="input-row">
<input
ref={passwordInputRef}
type="password"
placeholder="Click to enter password"
onClick={handlePasswordKeypad}
readOnly
/>
<button onClick={handlePasswordSubmit}>Submit</button>
</div>
{passwordResult && (
<pre className="result">{JSON.stringify(passwordResult, null, 2)}</pre>
)}
</div>
<div className="input-group">
<label>PIN (6 digits):</label>
<div className="input-row">
<input
ref={pinInputRef}
type="password"
placeholder="Click to enter PIN"
onClick={handlePinKeypad}
readOnly
/>
<button onClick={handlePinSubmit}>Submit</button>
</div>
{pinResult && (
<pre className="result">{JSON.stringify(pinResult, null, 2)}</pre>
)}
</div>
<div className="input-group">
<label>Card Number (16 digits):</label>
<div className="input-row">
<input
ref={cardNumberInputRef}
type="password"
placeholder="Click to enter card number"
onClick={handleCardKeypad}
readOnly
/>
<button onClick={handleCardSubmit}>Submit</button>
</div>
{cardResult && (
<pre className="result">{JSON.stringify(cardResult, null, 2)}</pre>
)}
</div>
</div>
<div className="demo-actions">
<button onClick={closeAllKeypads} className="secondary">Close All Keypads</button>
<button onClick={clearAll} className="danger">Clear All</button>
</div>
</div>
);
};
export default XkeypadPage;

View File

@@ -0,0 +1,314 @@
/* XKeypad Sample Page Styles */
.xkeypad-sample-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.sample-card {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
padding: 40px;
width: 100%;
max-width: 500px;
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sample-card h1 {
color: #2d3748;
font-size: 28px;
font-weight: 700;
margin: 0 0 12px 0;
text-align: center;
}
.description {
color: #718096;
text-align: center;
margin-bottom: 30px;
font-size: 15px;
line-height: 1.6;
}
.password-form {
margin-bottom: 30px;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
color: #4a5568;
font-weight: 600;
margin-bottom: 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-wrapper {
position: relative;
}
.password-input {
width: 100%;
padding: 14px 45px 14px 16px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 16px;
transition: all 0.3s ease;
background: #f7fafc;
cursor: pointer;
box-sizing: border-box;
}
.password-input:hover {
border-color: #cbd5e0;
background: #fff;
}
.password-input:focus {
outline: none;
border-color: #667eea;
background: #fff;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.input-icon {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 20px;
pointer-events: none;
}
.help-text {
display: block;
color: #a0aec0;
font-size: 13px;
margin-top: 8px;
font-style: italic;
}
.button-group {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
}
.submit-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.submit-btn:active {
transform: translateY(0);
}
.reset-btn {
background: #f7fafc;
color: #4a5568;
border: 2px solid #e2e8f0;
padding: 14px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.reset-btn:hover {
background: #e2e8f0;
border-color: #cbd5e0;
}
/* Result Section */
.result-section {
background: #f0f7ff;
border: 1px solid #bee3f8;
border-radius: 12px;
padding: 20px;
margin-bottom: 30px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.result-section h3 {
color: #2b6cb6;
font-size: 16px;
font-weight: 600;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.result-content {
background: white;
border-radius: 8px;
padding: 15px;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
}
.result-item:last-child {
border-bottom: none;
}
.result-item .label {
color: #718096;
font-weight: 500;
font-size: 14px;
}
.result-item .value {
color: #2d3748;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 600;
}
.result-item .value.encrypted {
color: #667eea;
word-break: break-all;
text-align: right;
max-width: 300px;
font-size: 12px;
}
/* Info Section */
.info-section {
background: #faf5ff;
border: 1px solid #e9d8fd;
border-radius: 12px;
padding: 20px;
}
.info-section h3 {
color: #553c9a;
font-size: 16px;
font-weight: 600;
margin: 0 0 15px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-section ul {
list-style: none;
padding: 0;
margin: 0;
}
.info-section li {
color: #44337a;
padding: 8px 0;
border-bottom: 1px solid #e9d8fd;
font-size: 14px;
display: flex;
align-items: center;
}
.info-section li:last-child {
border-bottom: none;
}
/* Loading State */
.loading {
text-align: center;
padding: 40px;
background: white;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.loading p {
color: #4a5568;
font-size: 18px;
margin: 0;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Responsive Design */
@media (max-width: 600px) {
.sample-card {
padding: 30px 20px;
}
.sample-card h1 {
font-size: 24px;
}
.button-group {
grid-template-columns: 1fr;
}
.password-input {
font-size: 16px; /* iOS zoom 방지 */
}
}
/* 키패드가 열릴 때 스크롤 방지 */
body.keypad-open {
overflow: hidden;
position: fixed;
width: 100%;
}

View File

@@ -0,0 +1,219 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPasswordKeypad, XKeypad, XKeypadManager, type XKeypadResult } from '../../utils/xkeypad';
import './xkeypad-sample.css';
export const XkeypadSample: React.FC = () => {
const [password, setPassword] = useState('');
const [result, setResult] = useState<XKeypadResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showResult, setShowResult] = useState(false);
const passwordInputRef = useRef<HTMLInputElement>(null);
const passwordKeypadRef = useRef<XKeypad | null>(null);
// RSA Keys (실제 프로덕션에서는 서버에서 받아와야 함)
const RSA_MODULUS = "C4F7B39E2E93DB19C016C7A0C1C05B028A1D57CB9B91E13F5B7353F8FB5AC6CE6BE31ABEB8E8F7AD18B90C08F4EBC011A6A8FCE614EA879ED5B96296B969CE92923BC9BAD6FD87F00E08F529F93010EA77E40937BDAC1C866E79ACE2F2822A3ECD982F90532D5301CF90D9BF89E953A0593AB6C5F31E99B690DD582FB85F85A9";
const RSA_EXPONENT = "10001";
// 키패드 초기화
useEffect(() => {
const initializeKeypad = async () => {
try {
const manager = XKeypadManager.getInstance({
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
await manager.loadScripts();
setIsLoading(false);
} catch (error) {
console.error('Failed to load XKeypad:', error);
setIsLoading(false);
}
};
initializeKeypad();
return () => {
// Cleanup
if (passwordKeypadRef.current) {
passwordKeypadRef.current.destroy();
}
};
}, []);
// 비밀번호 입력 처리
const handlePasswordInput = async () => {
if (!passwordInputRef.current || isLoading) return;
// 키패드가 없으면 생성
if (!passwordKeypadRef.current) {
passwordKeypadRef.current = createPasswordKeypad(passwordInputRef.current, {
keyType: 'qwertysmart',
viewType: 'half',
maxInputSize: 16,
useOverlay: true, // 오버레이 사용
useModal: false,
hasPressEffect: true,
onInputChange: (length: number) => {
console.log('Password length:', length);
},
onKeypadClose: () => {
console.log('Keypad closed');
// 키패드가 닫힐 때 입력값 업데이트
if (passwordInputRef.current) {
setPassword(passwordInputRef.current.value);
}
}
}, {
modulus: RSA_MODULUS,
exponent: RSA_EXPONENT
});
}
const result = await passwordKeypadRef.current.initialize(passwordInputRef.current);
if (result === 0) {
console.log('Keypad opened successfully');
} else if (result === -1) {
alert('지원하지 않는 기기입니다.');
}
};
// 비밀번호 제출
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!passwordKeypadRef.current) {
alert('비밀번호를 입력해주세요.');
return;
}
const keypadResult = passwordKeypadRef.current.getValue();
if (!keypadResult || !keypadResult.plainText) {
alert('비밀번호를 입력해주세요.');
return;
}
// 결과 저장 및 표시
setResult(keypadResult);
setShowResult(true);
console.log('Password submitted:', keypadResult);
// 서버로 전송할 데이터
const dataToSend = {
encryptedPassword: keypadResult.rsaEncrypted || keypadResult.plainText,
timestamp: new Date().toISOString()
};
console.log('Data to send to server:', dataToSend);
// TODO: 서버로 전송
// await sendToServer(dataToSend);
};
// 초기화
const handleReset = () => {
if (passwordKeypadRef.current) {
passwordKeypadRef.current.clear();
}
if (passwordInputRef.current) {
passwordInputRef.current.value = '';
}
setPassword('');
setResult(null);
setShowResult(false);
};
if (isLoading) {
return (
<div className="xkeypad-sample-container">
<div className="loading">
<p> ...</p>
</div>
</div>
);
}
return (
<div className="xkeypad-sample-container">
<div className="sample-card">
<h1> </h1>
<p className="description">
.
</p>
<form onSubmit={handleSubmit} className="password-form">
<div className="form-group">
<label htmlFor="password"></label>
<div className="input-wrapper">
<input
ref={passwordInputRef}
id="password"
type="password"
placeholder="클릭하여 비밀번호 입력"
onClick={handlePasswordInput}
readOnly
className="password-input"
/>
<span className="input-icon">🔒</span>
</div>
<small className="help-text">
.
</small>
</div>
<div className="button-group">
<button type="submit" className="submit-btn">
</button>
<button type="button" onClick={handleReset} className="reset-btn">
</button>
</div>
</form>
{showResult && result && (
<div className="result-section">
<h3> </h3>
<div className="result-content">
<div className="result-item">
<span className="label">Type:</span>
<span className="value">{result.type}</span>
</div>
{result.plainText && (
<div className="result-item">
<span className="label">Plain Text:</span>
<span className="value">{'•'.repeat(result.plainText.length)}</span>
</div>
)}
{result.rsaEncrypted && (
<div className="result-item">
<span className="label">RSA Encrypted:</span>
<span className="value encrypted">{result.rsaEncrypted.substring(0, 50)}...</span>
</div>
)}
</div>
</div>
)}
<div className="info-section">
<h3></h3>
<ul>
<li> RSA </li>
<li> </li>
<li> </li>
<li> </li>
<li> / </li>
</ul>
</div>
</div>
</div>
);
};
export default XkeypadSample;

View File

@@ -0,0 +1,250 @@
/* XKeypad Demo Styles */
.xkeypad-demo-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.xkeypad-demo-container h1 {
color: #333;
margin-bottom: 30px;
padding-bottom: 10px;
border-bottom: 2px solid #007bff;
}
.demo-section {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.demo-section h2 {
color: #495057;
font-size: 1.2rem;
margin-bottom: 20px;
}
.option-group {
margin-bottom: 15px;
padding: 10px;
background: white;
border-radius: 4px;
}
.option-group label {
display: block;
margin-bottom: 5px;
}
.option-group label strong {
display: block;
margin-bottom: 8px;
color: #495057;
}
.option-group label input[type="radio"] {
margin-right: 8px;
margin-left: 15px;
}
.option-group label input[type="checkbox"] {
margin-right: 8px;
}
.option-group > label > div {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.option-group > label > div > label {
display: flex;
align-items: center;
margin: 0;
}
.input-group {
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 4px;
}
.input-group > label {
display: block;
font-weight: bold;
margin-bottom: 10px;
color: #495057;
}
.input-row {
display: flex;
gap: 10px;
align-items: center;
}
.input-row input {
flex: 1;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.input-row input:hover {
border-color: #80bdff;
}
.input-row input:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
.input-row button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.input-row button:hover {
background-color: #0056b3;
}
.result {
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
overflow-x: auto;
}
.demo-actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 30px;
}
.demo-actions button {
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
}
.demo-actions button:hover {
opacity: 0.9;
}
.demo-actions button.secondary {
background-color: #6c757d;
color: white;
}
.demo-actions button.danger {
background-color: #dc3545;
color: white;
}
/* Modal Styles */
.xkeypad-modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
display: none;
}
.xkeypad-modal-wrapper.show {
display: block;
}
.xkeypad-modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
opacity: 0;
transition: opacity 0.3s ease;
}
.xkeypad-modal-wrapper.show .xkeypad-modal-overlay {
opacity: 1;
}
.xkeypad-modal-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 9999;
transform: translateY(100%);
transition: transform 0.3s ease;
padding: 20px;
max-height: 80vh;
overflow-y: auto;
}
.xkeypad-modal-wrapper.show .xkeypad-modal-container {
transform: translateY(0);
}
.xkeypad-modal-content {
position: relative;
width: 100%;
height: auto;
}
/* Responsive Design */
@media (max-width: 768px) {
.xkeypad-demo-container {
padding: 10px;
}
.demo-section {
padding: 15px;
}
.input-row {
flex-direction: column;
}
.input-row input {
width: 100%;
}
.input-row button {
width: 100%;
}
.demo-actions {
flex-direction: column;
}
.demo-actions button {
width: 100%;
}
}