- 약관 동의서 노출 및 동작 방식 관리 기능 추가
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run

- '전체 동의' 및 약관 항목별 활성화 설정 반영
- 사용자 타입 및 약관 페이지 구성에 따른 렌더링 로직 개선
This commit is contained in:
Rinjae(gf63)
2026-09-15 13:42:27 +09:00
parent 094336ebf1
commit ddca2a8ea4
14 changed files with 311 additions and 147 deletions
@@ -1,10 +1,16 @@
package com.eactive.apim.portal.apps.agreements.controller;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import com.eactive.apim.portal.apps.agreements.dto.AgreementTabDTO;
import com.eactive.apim.portal.apps.agreements.dto.AgreementsDTO;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -21,38 +27,56 @@ public class AgreementsController {
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
/**
* 약관 종류별 {@code tab} 파라미터 값. 기존 북마크/외부 링크 호환을 위해 슬러그를 유지한다.
* (초대 팝업 등에서 {@code tab=notification} 으로 직접 링크한다)
*/
private static final Map<AgreementType, String> TAB_SLUGS = new EnumMap<>(AgreementType.class);
static {
TAB_SLUGS.put(AgreementType.TERMS_OF_USE, "terms");
TAB_SLUGS.put(AgreementType.PRIVACY_POLICY, "privacy");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT, "privacy-collect");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT_IND, "privacy-collect-ind");
TAB_SLUGS.put(AgreementType.PRIVACY_COLLECT_ORG, "privacy-collect-org");
TAB_SLUGS.put(AgreementType.NOTIFICATION_CONSENT, "notification");
}
private final AgreementsFacade agreementsFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
@Autowired
public AgreementsController(AgreementsFacade agreementsFacade) {
public AgreementsController(AgreementsFacade agreementsFacade,
AgreementTypeConfigService agreementTypeConfigService) {
this.agreementsFacade = agreementsFacade;
this.agreementTypeConfigService = agreementTypeConfigService;
}
@GetMapping("/terms")
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
if ("privacy".equals(tab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
// 노출 대상과 순서는 관리 콘솔의 '약관 종류 관리'에서 설정한다
List<AgreementType> displayTypes = agreementTypeConfigService.getDisplayTypes();
// 요청한 탭이 노출 대상이 아니면(미배치/사용안함) 첫 번째 탭으로 보정한다
AgreementType type = resolveType(tab, displayTypes);
if (type == null) {
// 노출할 약관 종류가 하나도 없는 경우 — 빈 화면으로 방어
model.addAttribute("termsTabs", Collections.<AgreementTabDTO>emptyList());
model.addAttribute("agreementsList", Collections.<AgreementsDTO>emptyList());
model.addAttribute("selectedAgreement", null);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("agreementTitle", "약관");
model.addAttribute("agreementType", null);
model.addAttribute("currentTab", null);
return TERMS_AGREEMENTS;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
@@ -68,17 +92,40 @@ public class AgreementsController {
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("termsTabs", buildTabs(displayTypes, type));
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", currentTab);
model.addAttribute("currentTab", TAB_SLUGS.get(type));
return TERMS_AGREEMENTS;
}
/** 요청한 탭 슬러그를 노출 대상 약관 종류로 해석한다. 없거나 노출 대상이 아니면 첫 번째 탭. */
private AgreementType resolveType(String tab, List<AgreementType> displayTypes) {
if (displayTypes.isEmpty()) {
return null;
}
if (tab != null && !tab.isEmpty()) {
for (AgreementType displayType : displayTypes) {
if (tab.equals(TAB_SLUGS.get(displayType))) {
return displayType;
}
}
}
return displayTypes.get(0);
}
private List<AgreementTabDTO> buildTabs(List<AgreementType> displayTypes, AgreementType currentType) {
List<AgreementTabDTO> tabs = new ArrayList<>();
for (AgreementType displayType : displayTypes) {
tabs.add(new AgreementTabDTO(
TAB_SLUGS.get(displayType),
displayType.getDescription(),
displayType == currentType));
}
return tabs;
}
private AgreementsDTO findAgreementByDate(List<AgreementsDTO> agreements, String publishedOn) {
return agreements.stream()
.filter(a -> publishedOn.equals(
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.apps.agreements.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 약관 페이지({@code /agreements/terms})의 탭 하나.
*
* <p>노출 대상과 순서는 관리 콘솔의 '약관 종류 관리'에서 설정한 값
* ({@code PTL_PROPERTY Portal/portal.terms.display-types})을 따른다.
*/
@Getter
@AllArgsConstructor
public class AgreementTabDTO {
/** URL 파라미터 {@code tab} 값 (북마크 호환을 위해 기존 슬러그를 유지한다) */
private final String tab;
/** 탭에 표시할 약관명 */
private final String title;
/** 현재 선택된 탭인지 여부 */
private final boolean active;
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.agreements.service;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import java.util.Set;
/**
* 약관 동의 폼({@code apps/register/userAgreementContent :: agreementContent})에 필요한 모델 속성을 채운다.
*
* <p>관리 콘솔의 '약관 종류 관리'에서 사용하지 않도록 설정한 약관 종류는 동의 항목 자체를 노출하지 않는다
* ({@code show*} 플래그가 {@code false} 이고 내용도 {@code null}).
*
* @see AgreementTypeConfigService
*/
@Component
@RequiredArgsConstructor
public class AgreementFormModelSupport {
private final AgreementsFacade agreementsFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
/** 이용약관 / 개인정보수집동의서 / 알림 수신 동의서 모델 속성을 사용 여부에 맞춰 채운다. */
public void applyAgreements(Model model) {
applyAgreeAllMode(model);
Set<AgreementType> enabledTypes = agreementTypeConfigService.getEnabledTypes();
applyAgreement(model, enabledTypes, AgreementType.TERMS_OF_USE, "termsOfUse", "showTermsOfUse");
applyAgreement(model, enabledTypes, AgreementType.PRIVACY_COLLECT, "privacyCollect", "showPrivacyCollect");
applyAgreement(model, enabledTypes, AgreementType.NOTIFICATION_CONSENT, "notificationConsent",
"showNotificationConsent");
}
/**
* '전체 동의' 허용 여부만 모델에 담는다.
*
* <p>동의 항목 구성은 그대로 두고 동의 방식만 설정에 맞추면 되는 화면(법인 전환 등)에서 쓴다.
* {@code false} 면 '전체 동의' 체크박스를 숨기고 항목별로 끝까지 읽어야 동의할 수 있다.
*/
public void applyAgreeAllMode(Model model) {
model.addAttribute("agreeAllEnabled", agreementTypeConfigService.isAgreeAllEnabled());
}
private void applyAgreement(Model model, Set<AgreementType> enabledTypes, AgreementType type,
String contentAttribute, String flagAttribute) {
boolean enabled = enabledTypes.contains(type);
model.addAttribute(flagAttribute, enabled);
model.addAttribute(contentAttribute, enabled ? agreementsFacade.getAgreement(type.getCode()) : null);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
@@ -51,6 +52,7 @@ public class AccountController {
private final UserFacade userFacade;
private final OrgRegisterFacade orgRegisterFacade;
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@@ -365,6 +367,8 @@ public class AccountController {
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
// 동의 항목 구성은 이 화면 그대로 두고 '전체 동의' 허용 여부만 설정을 따른다
agreementFormModelSupport.applyAgreeAllMode(model);
return "apps/mypage/orgTransfer";
} catch (Exception e) {
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -21,7 +21,7 @@ public class OrgRegisterController {
private static final String MAIN_ORG_REGISTER = "apps/register/orgUserRegister";
private static final String MAIN_REGISTER_RESULT = "apps/register/userRegisterResult";
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final PortalProperties portalProperties;
private final OrgRegisterFacade orgRegisterFacade;
@@ -30,9 +30,8 @@ public class OrgRegisterController {
model.addAttribute("registrationType", "corporate");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
return MAIN_ORG_REGISTER;
}
@@ -105,8 +104,7 @@ public class OrgRegisterController {
model.addAttribute("registrationType", "corporate");
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
}
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.agreements.service.AgreementTypeConfigService;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam;
public class UserManController {
private final UserManFacade userManFacade;
private final AgreementTypeConfigService agreementTypeConfigService;
@GetMapping
public String userList(@PageableDefault(sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable, Model model) {
@@ -34,6 +37,10 @@ public class UserManController {
model.addAttribute("pendingUsers", pendingUsers);
model.addAttribute("page", users);
model.addAttribute("currentUserId", SecurityUtil.getPortalAuthenticatedUser().getId());
// 초대/초대 취소 팝업의 알림 수신 동의 체크박스 노출 여부.
// 약관 페이지에 배치되어 있어야(= 사용 중이고 미배치가 아니어야) 동의서 링크를 안내할 수 있다.
model.addAttribute("notificationConsentAvailable",
agreementTypeConfigService.isDisplayed(AgreementType.NOTIFICATION_CONSENT));
return "apps/users/userList";
}
@@ -1,6 +1,6 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.agreements.service.AgreementFormModelSupport;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -54,7 +54,7 @@ public class UserRegisterController {
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
private final AgreementFormModelSupport agreementFormModelSupport;
private final PortalProperties portalProperties;
private final UserInvitationRepository userInvitationRepository;
private final AgreementValidator agreementValidator;
@@ -94,9 +94,8 @@ public class UserRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
return MAIN_USER_REGISTER;
@@ -360,9 +359,8 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
@@ -391,9 +389,8 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
@@ -435,9 +432,8 @@ public class UserRegisterController {
model.addAttribute("error", errorMessage);
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
// 사용하지 않도록 설정한 약관 종류는 동의 항목을 노출하지 않는다 (관리 콘솔 > 약관 종류 관리)
agreementFormModelSupport.applyAgreements(model);
model.addAttribute("msgType", "sms");
}
@@ -377,10 +377,13 @@
return false;
}
// 사용하지 않도록 설정한 약관은 화면에 없으므로(관리 콘솔 > 약관 종류 관리) 없으면 통과 처리
const termsOfUseEl = document.getElementById('termsOfUse');
const privacyCollectEl = document.getElementById('privacyCollect');
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
termsOfUse: !termsOfUseEl || termsOfUseEl.checked,
privacyPolicy: !privacyCollectEl || privacyCollectEl.checked,
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
@@ -423,13 +426,15 @@
}
// 공통 항목
const termsOfUseEl = document.getElementById('termsOfUse');
const privacyCollectEl = document.getElementById('privacyCollect');
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (termsOfUseEl && !termsOfUseEl.checked) errors.push('이용약관에 동의해주세요.');
if (privacyCollectEl && !privacyCollectEl.checked) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
@@ -11,7 +11,14 @@
</div>
<form id="agreementForm" class="agreement-form">
<!-- Agree All Checkbox -->
<!--/*
'전체 동의' 허용 여부(관리 콘솔 > 약관 종류 관리)에 따라 두 가지 방식으로 동작한다.
- 허용 : '전체 동의' 체크박스를 노출하고, 항목별 스크롤 없이 바로 동의할 수 있다
- 미허용 : '전체 동의' 체크박스가 없고, 각 항목을 펼쳐 끝까지 읽어야 동의가 활성화된다
agreeAllEnabled 를 내려주지 않는 화면은 미허용(스크롤 동의)으로 동작한다.
*/-->
<!-- Agree All Checkbox : '전체 동의'를 허용할 때만 노출 -->
<th:block th:if="${agreeAllEnabled != null and agreeAllEnabled}">
<div class="agreement-all-section">
<label class="agreement-checkbox-label">
<input type="checkbox" id="agree_all" class="agreement-checkbox-input">
@@ -22,14 +29,16 @@
<!-- Divider -->
<div class="agreement-divider"></div>
</th:block>
<!-- Individual Agreements -->
<div class="agreement-items-list">
<!-- Terms of Use -->
<!-- Terms of Use (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
<th:block th:if="${showTermsOfUse == null or showTermsOfUse}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="termsOfUse" id="termsOfUse" class="agreement-checkbox-input"
required disabled>
required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
@@ -46,15 +55,18 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="termsOfUse" disabled>동의</button>
data-checkbox-id="termsOfUse" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
<!-- Privacy Policy -->
</th:block>
<!-- Privacy Collect (사용하지 않도록 설정하면 숨김. 플래그가 없는 화면은 기존대로 노출) -->
<th:block th:if="${showPrivacyCollect == null or showPrivacyCollect}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="privacyCollect" id="privacyCollect"
class="agreement-checkbox-input" required disabled>
class="agreement-checkbox-input" required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<th:block th:switch="${registrationType}">
<span class="agreement-checkbox-text" th:case="'personal'">
@@ -78,16 +90,18 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="privacyCollect" disabled>동의</button>
data-checkbox-id="privacyCollect" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
<th:block th:if="${notificationConsent != null}">
</th:block>
<!-- Notification Consent (사용하지 않도록 설정했거나 내용이 없으면 숨김) -->
<th:block th:if="${(showNotificationConsent == null or showNotificationConsent) and notificationConsent != null}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="notificationConsent" id="notificationConsent"
class="agreement-checkbox-input" required disabled>
class="agreement-checkbox-input" required th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
@@ -105,7 +119,7 @@
</div>
<div class="agreement-btn-wrapper" style="text-align: center; margin-top: 15px;">
<button type="button" class="btn-action-primary md agreement-agree-btn"
data-checkbox-id="notificationConsent" disabled>동의</button>
data-checkbox-id="notificationConsent" th:disabled="${agreeAllEnabled == null or !agreeAllEnabled}">동의</button>
</div>
</div>
</th:block>
@@ -276,6 +290,12 @@
row.style.cursor = 'pointer';
row.addEventListener('click', function (e) {
if (e.target.closest('.agreement-checkbox-input') || e.target.closest('.agreement-checkbox-custom')) {
// 개별 체크박스도 전체동의와 동일하게, 아직 다 읽지 않았으면 경고 알림 표시
const checkbox = this.querySelector('.agreement-checkbox-input');
if (checkbox && checkbox.disabled) {
e.preventDefault();
customPopups.showAlert('약관을 상세히 펼쳐서 끝까지 스크롤하여 읽으신 후에 동의하실 수 있습니다.');
}
return;
}
const toggleIcon = this.querySelector('.agreement-toggle-icon');
@@ -122,8 +122,9 @@
isPasswordValid &&
isPasswordMatch &&
userNameElement.val().trim() !== '' &&
termsOfUseElement.prop('checked') &&
privacyCollectElement.prop('checked') &&
// 사용하지 않도록 설정한 약관은 화면에 없으므로(관리 콘솔 > 약관 종류 관리) 없으면 통과 처리
(termsOfUseElement.length === 0 || termsOfUseElement.prop('checked')) &&
(privacyCollectElement.length === 0 || privacyCollectElement.prop('checked')) &&
(notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) &&
mobileNumberElement.val().trim() !== '' &&
isAuthVerified;
@@ -23,7 +23,7 @@
<div class="search-box">
<button type="button" class="btn btn-primary" id="addUserBtn">
<i class="fas fa-plus"></i>
<span>개발자 추가</span>
<span>개발자 초대</span>
</button>
</div>
</div>
@@ -96,7 +96,7 @@
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
초대취소
초대 취소
</button>
</div>
<!-- Action Dropdown (Mobile) -->
@@ -139,7 +139,7 @@
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
초대취소
초대 취소
</button>
</div>
</div>
@@ -163,7 +163,7 @@
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedMobileNumber}">
초대취소
초대 취소
</button>
</div>
<!-- Mobile Dropdown -->
@@ -180,7 +180,7 @@
<button type="button" class="dropdown-item cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedMobileNumber}">
초대취소
초대 취소
</button>
</div>
</div>
@@ -294,7 +294,7 @@
});
}
// Add event listeners to the "개발자 추가" buttons
// Add event listeners to the "개발자 초대" buttons
const addUserBtn = document.getElementById('addUserBtn');
if (addUserBtn) {
@@ -5,33 +5,25 @@
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1 th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용 약관</h1>
<h1 th:text="${agreementTitle} ?: '약관'">이용 약관</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="terms-container">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title" th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용약관</h2>
<h2 class="common-title" th:text="${agreementTitle} ?: '약관'">이용약관</h2>
</div>
<!-- Tab Navigation -->
<div class="terms-tabs">
<a th:href="@{/agreements/terms(tab='terms')}"
<!-- Tab Navigation : 노출 대상과 순서는 관리 콘솔 '약관 종류 관리'에서 설정한다 -->
<div class="terms-tabs" th:if="${not #lists.isEmpty(termsTabs)}">
<a th:each="termsTab : ${termsTabs}"
th:href="@{/agreements/terms(tab=${termsTab.tab})}"
class="tab-link"
th:classappend="${isTermsOfUse ? 'active' : ''}">
th:classappend="${termsTab.active ? 'active' : ''}"
th:text="${termsTab.title}">
이용약관
</a>
<a th:href="@{/agreements/terms(tab='privacy-collect')}"
class="tab-link"
th:classappend="${isPrivacyCollect ? 'active' : ''}">
개인정보수집동의서
</a>
<a th:href="@{/agreements/terms(tab='notification')}"
class="tab-link"
th:classappend="${isNotification ? 'active' : ''}">
알림 수신 동의서
</a>
</div>
<!-- Version Selector -->
@@ -9,7 +9,7 @@
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title">초대취소</h3>
<h3 class="modal-title">초대 취소</h3>
<button type="button" class="modal-close" id="cancelInvitationPopupCloseButton">
<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>
@@ -24,8 +24,8 @@
<strong id="cancelInvitationTarget" style="color: #4B9BFF;"></strong>에 대한<br>초대를 취소하시겠습니까?
</p>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<!-- 알림 수신 동의 체크박스 : '알림 수신 동의서'가 약관 페이지에 배치되어 있을 때만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:if="${notificationConsentAvailable}">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="cancelInvitationNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
@@ -37,12 +37,20 @@
</span>
</label>
</div>
<!-- 알림 수신 동의서를 사용하지 않거나 약관 페이지에 배치하지 않은 경우 : 안내 문구만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:unless="${notificationConsentAvailable}">
<p style="margin: 0; font-size: 13px; color: #475569; line-height: 1.5;">
현재 알림 수신 동의서를 운영하지 않아 수신자에게 초대 취소 알림 메시지가 발송되지 않습니다.
초대 취소는 정상적으로 진행됩니다.
</p>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-submit" id="cancelInvitationPopupCancelButton">닫기</button>
<button type="button" class="btn btn-primary btn-submit" id="cancelInvitationPopupConfirmButton">초대취소</button>
<button type="button" class="btn btn-primary btn-submit" id="cancelInvitationPopupConfirmButton">초대 취소</button>
</div>
</div>
@@ -9,7 +9,7 @@
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title">개발자 추가</h3>
<h3 class="modal-title">개발자 초대</h3>
<button type="button" class="modal-close" id="userInvitePopupCloseButton">
<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>
@@ -21,7 +21,7 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="userInvitePopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
추가할 개발자 휴대폰 번호를 입력해 주세요.
초대할 개발자 휴대폰 번호를 입력해 주세요.
</p>
<!-- Mobile Input Field -->
@@ -34,8 +34,8 @@
<div id="userInvitePopupError" class="error-message"></div>
</div>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<!-- 알림 수신 동의 체크박스 : '알림 수신 동의서'가 약관 페이지에 배치되어 있을 때만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:if="${notificationConsentAvailable}">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="userInviteNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
@@ -47,6 +47,14 @@
</span>
</label>
</div>
<!-- 알림 수신 동의서를 사용하지 않거나 약관 페이지에 배치하지 않은 경우 : 안내 문구만 노출 -->
<div class="pop_consent_group" style="margin-top: 16px;" th:unless="${notificationConsentAvailable}">
<p style="margin: 0; font-size: 13px; color: #475569; line-height: 1.5;">
현재 알림 수신 동의서를 운영하지 않아 수신자에게 초대 메시지가 발송되지 않습니다.
초대는 정상적으로 진행되며, 초대받은 개발자에게 직접 안내해 주세요.
</p>
</div>
</div>
<!-- Modal Footer -->