Use numeric PIN keypad for launcher auth
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
- 차량 모니터 이동
|
||||
- 공통 암호 로그인
|
||||
- 공통 암호 변경
|
||||
- 숫자 전용 PIN 입력과 커스텀 숫자 키패드
|
||||
- 사이트 관리자 로그인 상태에서만 관리 버튼 표시
|
||||
- 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호 생략
|
||||
- 사이트 관리자 세션으로 미니앱 진입 시 공통 접근 쿠키 자동 발급
|
||||
@@ -38,4 +39,6 @@
|
||||
|
||||
메인 버튼은 기존 4개를 유지합니다. Rhymix 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다.
|
||||
|
||||
암호는 숫자만 허용합니다. 로그인과 암호 변경 입력창은 `inputmode=numeric`, `[0-9]*` 패턴, 커스텀 숫자 키패드를 사용하며 Android/iPhone에서 문자 키보드가 뜨지 않도록 `readonly` 입력창에 화면 키패드로 값을 넣습니다. 서버 API도 숫자가 아닌 암호를 거부합니다.
|
||||
|
||||
암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다.
|
||||
|
||||
@@ -8,12 +8,21 @@ header('Cache-Control: no-store');
|
||||
|
||||
$config = custom_config();
|
||||
|
||||
function launcher_is_digit_password(string $password): bool
|
||||
{
|
||||
return $password !== '' && ctype_digit($password);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = custom_read_request_data();
|
||||
$action = $data['action'] ?? 'login';
|
||||
|
||||
if ($action === 'login') {
|
||||
$password = (string)($data['password'] ?? '');
|
||||
if (!launcher_is_digit_password($password)) {
|
||||
custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 401);
|
||||
}
|
||||
|
||||
if (custom_verify_password($password)) {
|
||||
custom_issue_auth_cookie();
|
||||
custom_json(['result' => 'ok']);
|
||||
@@ -31,6 +40,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$current = (string)($data['currentPassword'] ?? '');
|
||||
$next = (string)($data['newPassword'] ?? '');
|
||||
|
||||
if (!launcher_is_digit_password($current) || !launcher_is_digit_password($next)) {
|
||||
custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 422);
|
||||
}
|
||||
|
||||
if (!custom_verify_password($current)) {
|
||||
custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401);
|
||||
}
|
||||
|
||||
+118
-17
@@ -97,7 +97,7 @@ function showPasswordForm(onSuccess) {
|
||||
shortcut.innerHTML = '';
|
||||
|
||||
const title = createFormHeader('비밀번호', renderIcons);
|
||||
const input = createPasswordInput('blog_secret', '비밀번호');
|
||||
const input = createPinInput('blog_secret', '비밀번호');
|
||||
const button = createButton('확인');
|
||||
const msg = document.createElement('div');
|
||||
msg.id = 'failMsg';
|
||||
@@ -114,7 +114,8 @@ function showPasswordForm(onSuccess) {
|
||||
shortcut.appendChild(column);
|
||||
|
||||
const submit = async () => {
|
||||
const password = input.value;
|
||||
const password = onlyDigits(input.value);
|
||||
input.value = password;
|
||||
|
||||
try {
|
||||
const messageRes = await fetch(`${apiUrl}?failMessages=1`);
|
||||
@@ -149,10 +150,10 @@ function showPasswordForm(onSuccess) {
|
||||
}
|
||||
};
|
||||
|
||||
const keypad = createPinKeypad([input], submit);
|
||||
column.appendChild(keypad);
|
||||
button.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submit();
|
||||
});
|
||||
attachPinInputEvents(input, submit);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
@@ -161,9 +162,9 @@ function showPasswordChangeForm() {
|
||||
shortcut.innerHTML = '';
|
||||
|
||||
const title = createFormHeader('암호 변경', renderIcons);
|
||||
const currentInput = createPasswordInput('current_password', '현재 암호');
|
||||
const newInput = createPasswordInput('new_password', '새 암호');
|
||||
const confirmInput = createPasswordInput('confirm_password', '새 암호 확인');
|
||||
const currentInput = createPinInput('current_password', '현재 암호');
|
||||
const newInput = createPinInput('new_password', '새 암호');
|
||||
const confirmInput = createPinInput('confirm_password', '새 암호 확인');
|
||||
const button = createButton('변경');
|
||||
const msg = document.createElement('div');
|
||||
msg.id = 'failMsg';
|
||||
@@ -179,14 +180,21 @@ function showPasswordChangeForm() {
|
||||
shortcut.appendChild(column);
|
||||
|
||||
const submit = async () => {
|
||||
const currentPassword = currentInput.value;
|
||||
const newPassword = newInput.value;
|
||||
const confirmPassword = confirmInput.value;
|
||||
const currentPassword = onlyDigits(currentInput.value);
|
||||
const newPassword = onlyDigits(newInput.value);
|
||||
const confirmPassword = onlyDigits(confirmInput.value);
|
||||
currentInput.value = currentPassword;
|
||||
newInput.value = newPassword;
|
||||
confirmInput.value = confirmPassword;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
msg.textContent = '새 암호가 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
if (!isDigitPin(currentPassword) || !isDigitPin(newPassword)) {
|
||||
msg.textContent = '암호는 숫자만 입력할 수 있습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl, {
|
||||
@@ -205,18 +213,20 @@ function showPasswordChangeForm() {
|
||||
}
|
||||
|
||||
msg.style.color = 'red';
|
||||
msg.textContent = data.message === 'password_too_short' ? '새 암호는 4자 이상이어야 합니다.' : '현재 암호가 틀립니다.';
|
||||
msg.textContent = data.message === 'password_too_short'
|
||||
? '새 암호는 4자리 이상이어야 합니다.'
|
||||
: (data.message === 'password_digits_only' ? '암호는 숫자만 입력할 수 있습니다.' : '현재 암호가 틀립니다.');
|
||||
} catch (_) {
|
||||
msg.style.color = 'red';
|
||||
msg.textContent = '요청을 처리하지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const keypad = createPinKeypad([currentInput, newInput, confirmInput], submit);
|
||||
column.insertBefore(keypad, msg);
|
||||
button.addEventListener('click', submit);
|
||||
[currentInput, newInput, confirmInput].forEach(input => {
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submit();
|
||||
});
|
||||
attachPinInputEvents(input, submit);
|
||||
});
|
||||
currentInput.focus();
|
||||
}
|
||||
@@ -350,15 +360,106 @@ function createFormHeader(text, onBack) {
|
||||
return header;
|
||||
}
|
||||
|
||||
function createPasswordInput(id, placeholder) {
|
||||
function onlyDigits(value) {
|
||||
return String(value || '').replace(/\D+/g, '');
|
||||
}
|
||||
|
||||
function isDigitPin(value) {
|
||||
return /^\d+$/.test(String(value || ''));
|
||||
}
|
||||
|
||||
function createPinInput(id, placeholder) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'password';
|
||||
input.placeholder = placeholder;
|
||||
input.id = id;
|
||||
input.autocomplete = 'current-password';
|
||||
input.inputMode = 'numeric';
|
||||
input.pattern = '[0-9]*';
|
||||
input.autocomplete = 'off';
|
||||
input.enterKeyHint = 'done';
|
||||
input.readOnly = true;
|
||||
input.className = 'launcher-pin-input';
|
||||
input.setAttribute('aria-label', placeholder);
|
||||
return input;
|
||||
}
|
||||
|
||||
function attachPinInputEvents(input, onEnter) {
|
||||
input.addEventListener('focus', () => {
|
||||
document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === input));
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
input.value = onlyDigits(input.value);
|
||||
});
|
||||
input.addEventListener('paste', e => {
|
||||
e.preventDefault();
|
||||
input.value += onlyDigits(e.clipboardData?.getData('text') || '');
|
||||
});
|
||||
input.addEventListener('keydown', e => {
|
||||
if (/^\d$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
input.value += e.key;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
input.value = input.value.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPinKeypad(inputs, onEnter) {
|
||||
let activeInput = inputs[0];
|
||||
const keypad = document.createElement('div');
|
||||
keypad.className = 'launcher-pin-keypad';
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('focus', () => {
|
||||
activeInput = input;
|
||||
});
|
||||
input.addEventListener('click', () => {
|
||||
activeInput = input;
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'clear', '0', 'back'].forEach(key => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `launcher-pin-key ${key === 'clear' || key === 'back' ? 'launcher-pin-action' : ''}`;
|
||||
btn.textContent = key === 'clear' ? 'C' : (key === 'back' ? '⌫' : key);
|
||||
btn.setAttribute('aria-label', key === 'clear' ? '전체 지우기' : (key === 'back' ? '한 글자 지우기' : `${key} 입력`));
|
||||
btn.addEventListener('click', () => {
|
||||
activeInput.focus();
|
||||
if (key === 'clear') {
|
||||
activeInput.value = '';
|
||||
} else if (key === 'back') {
|
||||
activeInput.value = activeInput.value.slice(0, -1);
|
||||
} else {
|
||||
activeInput.value += key;
|
||||
}
|
||||
});
|
||||
keypad.appendChild(btn);
|
||||
});
|
||||
|
||||
const done = document.createElement('button');
|
||||
done.type = 'button';
|
||||
done.className = 'launcher-pin-key launcher-pin-done';
|
||||
done.textContent = '확인';
|
||||
done.addEventListener('click', onEnter);
|
||||
keypad.appendChild(done);
|
||||
|
||||
return keypad;
|
||||
}
|
||||
|
||||
function createButton(text) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
|
||||
Reference in New Issue
Block a user