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