merge 충돌 해결

This commit is contained in:
hong
2026-08-04 14:53:33 +09:00
53 changed files with 1469 additions and 294 deletions
+13
View File
@@ -1703,6 +1703,19 @@ hr {
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
File diff suppressed because one or more lines are too long
+60 -10
View File
@@ -2,8 +2,10 @@
* 비밀번호 문자열 정책 라이브 검증 (공용)
*
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
* 규칙을 클라이언트로 포팅한다. 아이디/휴대전화 포함 규칙(noid/nomobile)은
* 사용자가 폼에 직접 입력한 값이 페이지에 이미 있을 때만 ul 의
* data-context-loginid/data-context-mobile 로 연결해 쓴다 — DB 값을 새로
* 내려받아야 하는 화면(비밀번호 변경)은 서버 AJAX(/password/content-check)로 판정한다.
*
* 사용법(마크업 구동):
* <ul class="password-policy-checklist" data-password-input="newPassword">
@@ -31,7 +33,7 @@
return false;
}
// 규칙별 판정 함수 (통과=true)
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
@@ -39,23 +41,46 @@
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); }
noseq: function (pw) { return !hasSequential(pw); },
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
noid: function (pw, ctx) {
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
return !id || pw.toUpperCase().indexOf(id) === -1;
},
// 휴대전화 하이픈 세그먼트 포함 금지 — 서버 containsMobileSegment 포팅
nomobile: function (pw, ctx) {
var m = ctx && ctx.mobile ? String(ctx.mobile) : '';
if (!m) { return true; }
var up = pw.toUpperCase();
var parts = m.split('-');
for (var i = 0; i < parts.length; i++) {
if (parts[i] && up.indexOf(parts[i]) !== -1) {
return false;
}
}
return true;
}
};
// 전체 문자열 규칙 통과 여부
function isValid(pw) {
// 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
function isValid(pw, ctx) {
if (!pw) {
return false;
}
for (var key in RULES) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
return false;
}
}
return true;
}
// 체크리스트(ul) 하나를 대상 input 에 바인딩
// bind 된 체크리스트들의 update 함수 목록 (컨텍스트 값 변경 시 refresh 용)
var updaters = [];
// 체크리스트(ul) 하나를 대상 input 에 바인딩.
// ul 의 data-context-loginid / data-context-mobile 속성에 소스 input 의 id 를 주면
// noid/nomobile 규칙이 해당 값 기준으로 라이브 판정된다.
function bind(input, list) {
var $input = (input && input.jquery) ? input : $(input);
var $list = (list && list.jquery) ? list : $(list);
@@ -64,8 +89,18 @@
return;
}
function ctxValue(attr) {
var id = $list.attr(attr);
var el = id ? document.getElementById(id) : null;
return el ? el.value : '';
}
function update() {
var pw = $input.val() || '';
var ctx = {
loginId: ctxValue('data-context-loginid'),
mobile: ctxValue('data-context-mobile')
};
$items.each(function () {
var $li = $(this);
var rule = RULES[$li.attr('data-rule')];
@@ -76,15 +111,29 @@
if (pw.length === 0) {
$li.addClass('is-idle');
} else {
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
$li.addClass(rule(pw, ctx) ? 'is-pass' : 'is-fail');
}
});
}
// 컨텍스트 소스 input 이 직접 타이핑되는 경우도 즉시 반영
['data-context-loginid', 'data-context-mobile'].forEach(function (attr) {
var id = $list.attr(attr);
if (id && document.getElementById(id)) {
$(document.getElementById(id)).on('input.passwordPolicy change.passwordPolicy', update);
}
});
updaters.push(update);
$input.on('input.passwordPolicy', update);
update();
}
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
function refresh() {
updaters.forEach(function (u) { u(); });
}
// 마크업 구동 자동 초기화
function init(root) {
var $root = root ? $(root) : $(document);
@@ -101,7 +150,8 @@
RULES: RULES,
isValid: isValid,
bind: bind,
init: init
init: init,
refresh: refresh
};
$(function () {
@@ -246,6 +246,110 @@ const customPopups = {
$('#passwordPopupError').removeClass('show');
$('#passwordPopupInput').removeClass('error');
},
/**
* API 이용 해지 신청 팝업 표시 (경고문 + 사유 필수 — 본인 확인은 step-up 2FA가 담당)
* @param {Object} options - 팝업 옵션
* @param {Function} options.onConfirm - 해지 신청 버튼 클릭 시 호출되는 콜백 (파라미터: reason)
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showTerminateRequest: function (options) {
options = options || {};
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
// 입력 필드 및 에러 초기화
$('#terminateReasonInput').val('').removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
$('#terminateRequestPopup').show();
setTimeout(function() {
$('#terminateModalBackdrop').addClass('show');
$('#terminateModal').addClass('show');
}, 10);
// Body 스크롤 방지
$('body').css('overflow', 'hidden');
// 사유 입력 필드에 포커스
setTimeout(function() {
$('#terminateReasonInput').focus();
}, 350);
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#terminatePopupConfirmButton').off('click').on('click', function () {
const reason = $('#terminateReasonInput').val().trim();
if (!reason) {
customPopups.showTerminateError('해지 사유를 입력해주세요.');
$('#terminateReasonInput').addClass('error').focus();
return;
}
if (typeof onConfirm === 'function') {
onConfirm(reason);
}
});
// 취소 버튼 이벤트
$('#terminatePopupCancelButton').off('click').on('click', function () {
customPopups.hideTerminateRequest();
if (typeof onCancel === 'function') {
onCancel();
}
});
// 닫기 버튼 이벤트
$('#terminatePopupCloseButton').off('click').on('click', function () {
customPopups.hideTerminateRequest();
if (typeof onCancel === 'function') {
onCancel();
}
});
// 입력 시 에러 초기화
$('#terminateReasonInput').off('input').on('input', function () {
$(this).removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
});
},
/**
* 해지 신청 팝업 숨기기
*/
hideTerminateRequest: function () {
// Modal 숨김 애니메이션
$('#terminateModalBackdrop').removeClass('show');
$('#terminateModal').removeClass('show');
// 애니메이션 완료 후 숨김
setTimeout(function() {
$('#terminateRequestPopup').hide();
}, 300);
// Body 스크롤 복원
$('body').css('overflow', '');
// 입력 필드 초기화
$('#terminateReasonInput').val('').removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
// 이벤트 리스너 제거
$('#terminatePopupConfirmButton').off('click');
$('#terminatePopupCancelButton').off('click');
$('#terminatePopupCloseButton').off('click');
$('#terminateReasonInput').off('input');
},
/**
* 해지 신청 팝업 에러 메시지 표시
* @param {string} message - 에러 메시지
*/
showTerminateError: function (message) {
$('#terminatePopupError').text(message).addClass('show');
},
/**
* 확인 팝업 표시
* @param {string} message - 확인 메시지
@@ -407,8 +511,12 @@ const customPopups = {
return;
}
// 하이픈 유무 무관 입력을 저장 표준(010-1234-5678)으로 통일해 전달
const formattedMobile = mobile.replace(/-/g, '')
.replace(/^(01[016-9])(\d{3,4})(\d{4})$/, '$1-$2-$3');
if (typeof onConfirm === 'function') {
onConfirm(mobile, notifyConsent);
onConfirm(formattedMobile, notifyConsent);
}
});
@@ -65,6 +65,19 @@
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
@@ -49,7 +49,7 @@
<!-- Form Content -->
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="djb-board-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
<input type="hidden" th:field="*{id}" th:unless="${isNew}">
<!-- Subject Field -->
<div class="form-group">
@@ -85,7 +85,7 @@
<input type="file" id="inquiryImage" name="image" class="djb-input"
accept="image/png,image/jpeg,image/gif">
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
<small th:if="${isNew != true and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
</div>
@@ -206,7 +206,7 @@
const file = fileInput.files[0];
if (file) {
/*[# th:if="${!isInternalUser}"]*/
/*[# th:unless="${isInternalUser}"]*/
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
@@ -32,57 +32,7 @@
<!-- App List Container -->
<div class="app-list-container-figma">
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
<img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
<svg th:unless="${request.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
fill="none" stroke="#64748b" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<!-- App Info -->
<div class="app-card-info">
<div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
<!-- Status Badge -->
<span class="app-card-badge"
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
승인정보 없음
</span>
</div>
<!-- App Description & Expected Completion Date -->
<div class="app-card-footer-row">
<p class="app-card-desc"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
<!-- Expected date or placeholder to keep structure aligned -->
<span class="app-card-expected-date"
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토)
</span>
<span class="app-card-expected-date"
th:unless="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}">
예상 완료일 : -
</span>
</div>
</div>
</a>
</th:block>
<!-- API Keys (Approved/Inactive) -->
<!-- API Keys (Approved/Inactive) — 승인완료 우선 표시 -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
@@ -119,6 +69,59 @@
</a>
</th:block>
<!-- App Requests (Pending) — 진행중 → 요청됨, 최근 신청 순 -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
<img th:if="${request.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
<svg th:unless="${request.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
fill="none" stroke="#64748b" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<!-- App Info -->
<div class="app-card-info">
<div class="app-card-header">
<!-- App Name -->
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
<!-- Request Type Badge (해지 신청 구분) -->
<span class="app-card-badge badge-pending"
th:if="${request.type != null and request.type.name() == 'DELETE'}">해지 신청</span>
<!-- Status Badge -->
<span class="app-card-badge"
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
승인정보 없음
</span>
</div>
<!-- App Description & Expected Completion Date -->
<div class="app-card-footer-row">
<p class="app-card-desc"
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
앱 설명이 여기에 표시됩니다.
</p>
<!-- Expected date or placeholder to keep structure aligned -->
<span class="app-card-expected-date"
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토)
</span>
<span class="app-card-expected-date"
th:unless="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}">
예상 완료일 : -
</span>
</div>
</div>
</a>
</th:block>
<!-- Empty State -->
<div class="app-list-empty-figma"
th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}">
@@ -190,8 +190,10 @@
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
th:disabled="${pendingDeleteRequest}"
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : 'API 이용 해지'">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:unless="${pendingDeleteRequest}" class="dt-btn-blue"
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div>
@@ -295,44 +297,44 @@
document.body.removeChild(textArea);
}
// Delete API Key function - called from button with data-client-id attribute
// API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
function deleteApiKeyFromButton(button) {
var clientId = $(button).data('client-id');
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) {
if (!confirmed) {
return;
customPopups.showTerminateRequest({
onConfirm: function (reason) {
doDeleteApiKey(clientId, reason);
}
doDeleteApiKey(clientId);
});
}
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doDeleteApiKey(clientId) {
// 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doDeleteApiKey(clientId, reason) {
$('.loading-overlay').show();
$.ajax({
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
data: JSON.stringify({ clientId: clientId, reason: reason }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
customPopups.hideTerminateRequest();
customPopups.showAlert(response.msg || '해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.', function () {
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId, reason); });
return;
}
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function () {
$('.loading-overlay').hide();
});
@@ -70,8 +70,11 @@
class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
<li data-rule="noid-server" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">아이디(이메일) 포함 불가</span></li>
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
class="policy-text">휴대전화 번호 포함 불가</span></li>
</ul>
<p class="password-policy-note">※ 아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div>
<div class="form-actions" style="justify-content: flex-end;">
@@ -89,6 +92,52 @@
customPopups.showAlert([[${ error }]]);
})
</script>
<script th:inline="javascript">
// 아이디/휴대전화 포함 여부 라이브 체크 — 민감정보를 페이지에 내리지 않고
// 서버(/password/content-check, 세션 사용자 기준)로 판정한다. data-rule 이
// RULES 에 없는 *-server 항목은 password-policy.js 가 건드리지 않는다.
(function () {
var input = document.getElementById('newPassword');
var liId = document.querySelector('li[data-rule="noid-server"]');
var liMobile = document.querySelector('li[data-rule="nomobile-server"]');
if (!input || !liId || !liMobile) return;
function setState(li, state) {
li.classList.remove('is-idle', 'is-pass', 'is-fail');
li.classList.add(state);
}
var csrfToken = document.querySelector('meta[name="_csrf"]');
var csrfHeader = document.querySelector('meta[name="_csrf_header"]');
var timer = null;
input.addEventListener('input', function () {
var pw = input.value;
if (timer) clearTimeout(timer);
if (!pw) {
setState(liId, 'is-idle');
setState(liMobile, 'is-idle');
return;
}
timer = setTimeout(function () {
var headers = {};
if (csrfToken && csrfHeader) {
headers[csrfHeader.content] = csrfToken.content;
}
$.ajax({
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
method: 'POST',
headers: headers,
data: { password: pw }
}).done(function (res) {
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
});
}, 300);
});
})();
</script>
<script th:inline="javascript">
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
(function () {
@@ -402,16 +402,17 @@
const last = document.getElementById('newMobileLast')?.value.trim();
if (prefix === '선택' || !middle || !last) return null;
return prefix + middle + last;
// 저장 표준(하이픈 정규형)에 맞춰 조합
return `${prefix}-${middle}-${last}`;
},
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
// 기존 휴대폰 번호 가져오기 (하이픈 정규형)
getExistingMobileNumber: () => {
const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
const middle = document.querySelector('[name="mobileMiddle"]')?.value;
const last = document.querySelector('[name="mobileLast"]')?.value;
return prefix + middle + last;
return `${prefix}-${middle}-${last}`;
},
// 휴대폰 번호 변경 여부 확인
@@ -92,7 +92,8 @@
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
const originalLast = document.querySelector('input[name="mobileLast"]').value;
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`;
// 저장 표준(하이픈 정규형)에 맞춰 조합 — 미변경 제출 시에도 이 값이 그대로 전송된다
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
// 현재 값들 가져오기
const currentName = document.querySelector('input[name="userName"]').value.trim();
@@ -118,7 +119,7 @@
// 하이픈 제거 후 비교
const newMobileRaw = newMobileNumber.replace(/-/g, '');
if (newMobileRaw !== originalMobileNumber) {
if (newMobileRaw !== originalMobileNumber.replace(/-/g, '')) {
hasChanges = true;
}
@@ -80,7 +80,8 @@
th:placeholder="#{portalUser.Register.pass}">
<input type="hidden" name="isPasswordValid" id="isPasswordValid" />
<div id="password-validation" class="org-validation-message"></div>
<ul class="password-policy-checklist" data-password-input="password">
<ul class="password-policy-checklist" data-password-input="password"
data-context-loginid="loginId" data-context-mobile="mobileNumber">
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
포함 8~50자</span></li>
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문
@@ -95,8 +96,11 @@
3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자
3자리 이상 불가</span></li>
<li data-rule="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
포함 불가</span></li>
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
포함 불가</span></li>
</ul>
<p class="password-policy-note">※ 아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div>
</div>
@@ -141,6 +145,10 @@
if (mobileNumberInput) {
mobileNumberInput.value = formattedNumber;
}
// hidden 값 변경은 input 이벤트가 없으므로 체크리스트(nomobile) 수동 재판정
if (window.PasswordPolicy) {
PasswordPolicy.refresh();
}
return formattedNumber;
}
@@ -6,7 +6,7 @@
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="개인회원가입" class="title-image">
<h1 th:text="${!isInvited ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1>
<h1 th:text="${isInvited != true ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1>
</div>
</section>
@@ -23,7 +23,7 @@
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice" th:if="${!isInvited}">
<div class="org-info-notice" th:unless="${isInvited}">
<div class="notice-icon-wrapper">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
@@ -1,5 +1,5 @@
<div th:fragment="authNumberValidation">
<div th:if="${!isValid}" class="invalid-feedback d-block">
<div th:unless="${isValid}" class="invalid-feedback d-block">
올바른 인증번호를 입력해주세요.
</div>
<div th:if="${isValid}" class="valid-feedback d-block">
@@ -1,6 +1,6 @@
<div th:fragment="result">
<span id="validationResult"
th:text="${!isValid} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'"
th:class="${!isValid} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
th:text="${isValid != true} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'"
th:class="${isValid != true} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
</span>
</div>
@@ -7,31 +7,54 @@
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb_white.png" alt="DJBank" class="footer-logo">
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg
class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"
stroke-linejoin="round" />
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section -->
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
</footer>
</body>
<body>
<footer th:fragment="footerFragment" class="global-footer">
<div class="container">
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb_white.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section -->
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
</footer>
</body>
</html>
@@ -1,5 +1,5 @@
<div th:fragment="result">
<div th:if="${!isValidFormat}" class="invalid-feedback d-block">
<div th:unless="${isValidFormat}" class="invalid-feedback d-block">
올바른 이메일 형식이 아닙니다.
</div>
<div th:if="${isValidFormat}">
@@ -11,6 +11,6 @@
</div>
</div>
<span id="validationResult" style="display:none;"
th:text="${!isValidFormat} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')">
th:text="${isValidFormat != true} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')">
</span>
</div>
@@ -4,4 +4,5 @@
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
</div>
@@ -0,0 +1,49 @@
<!-- views/fragment/popup/terminateRequestPopup.html -->
<div th:fragment="terminateRequestPopup" id="terminateRequestPopup" style="display: none;">
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="terminateModalBackdrop"></div>
<!-- Modal Wrapper -->
<div class="modal" id="terminateModal">
<div class="modal-dialog">
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title" id="terminatePopupTitle">API 이용 해지 신청</h3>
<button type="button" class="modal-close" id="terminatePopupCloseButton">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="modal-body">
<p id="terminatePopupMessage" style="margin-bottom: 16px; color: #64748B; word-break: keep-all;">
해지 신청 후 <strong>관리자 승인 전까지 API는 정상 동작</strong>하며,<br>
승인이 완료되면 <strong>인증키가 삭제되고 API 호출이 차단</strong>됩니다.<br>
삭제된 인증키는 <strong>복구할 수 없습니다.</strong>
</p>
<!-- Reason Input Field (본인 확인은 step-up 2FA가 담당하므로 비밀번호 입력 없음) -->
<div class="pop_input_group">
<textarea id="terminateReasonInput"
class="pop_input_field"
rows="3"
maxlength="1000"
placeholder="해지 사유를 입력해주세요 (필수)"
style="resize: vertical; min-height: 72px;"></textarea>
<div id="terminatePopupError" class="error-message"></div>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="terminatePopupCancelButton">취소</button>
<button type="button" class="btn btn-primary" id="terminatePopupConfirmButton">해지 신청</button>
</div>
</div>
</div>
</div>
@@ -29,7 +29,7 @@
<input type="tel"
id="userInviteMobileInput"
class="pop_input_field"
placeholder="휴대폰 번호 ('-' 없이 입력)"
placeholder="휴대폰 번호 (예: 010-1234-5678)"
maxlength="13">
<div id="userInvitePopupError" class="error-message"></div>
</div>
@@ -2,7 +2,7 @@
<div th:if="${success}" class="valid-feedback d-block">
<span th:text="${message}">인증번호를 발송하였습니다.</span>
</div>
<div th:if="${!success}" class="invalid-feedback d-block">
<div th:unless="${success}" class="invalid-feedback d-block">
<span th:text="${message}">인증번호 발송 중 오류가 발생했습니다.</span>
</div>
</div>