Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -1,217 +0,0 @@
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
JAVA_HOME = '/apps/opts/jdk8'
|
||||
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
NODE_HOME = '/apps/opts/node-v24'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
CATALINA_BASE = '/prod/eapim/devportal'
|
||||
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
|
||||
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
|
||||
DEPLOY_HTTP_PORT = '39130'
|
||||
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout dependencies') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
cd "$WORKSPACE/.."
|
||||
|
||||
if [ ! -d elink-portal-common/.git ]; then
|
||||
rm -rf elink-portal-common
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||
elink-portal-common
|
||||
else
|
||||
git -C elink-portal-common fetch --depth=1 origin master
|
||||
git -C elink-portal-common reset --hard origin/master
|
||||
git -C elink-portal-common clean -fdx
|
||||
fi
|
||||
|
||||
mkdir -p eapim-online
|
||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||
rm -rf eapim-online/elink-online-core-jpa
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||
eapim-online/elink-online-core-jpa
|
||||
else
|
||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
java -version
|
||||
gradle --version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'gradle clean test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build WAR') {
|
||||
steps {
|
||||
sh 'gradle build -x test --no-daemon'
|
||||
}
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Stop Tomcat') {
|
||||
steps {
|
||||
sh '''
|
||||
set +e
|
||||
systemctl --user stop eapim-portal 2>/dev/null
|
||||
|
||||
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
|
||||
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
|
||||
for i in $(seq 1 30); do
|
||||
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
rm -f "$CATALINA_PID"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy ROOT.war') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
DEPLOY_DIR="$CATALINA_BASE/webapps"
|
||||
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
|
||||
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
|
||||
ls -la "$DEPLOY_DIR"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Start Tomcat and readiness') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
|
||||
BEFORE=0
|
||||
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
|
||||
|
||||
systemctl --user start eapim-portal
|
||||
|
||||
DEADLINE=$(($(date +%s) + 120))
|
||||
LIVE_OK=0
|
||||
STATUS=000
|
||||
|
||||
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||
if [ "$LIVE_OK" = "0" ]; then
|
||||
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
|
||||
if [ "$LIVE" = "200" ]; then
|
||||
echo "Liveness OK (/health)"
|
||||
LIVE_OK=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$LIVE_OK" = "1" ]; then
|
||||
STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
|
||||
"http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Readiness OK (/health/ready)"
|
||||
cat /tmp/eapim-ready-body.json
|
||||
echo
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
|
||||
echo "Startup error detected in log"
|
||||
tail -c +$((BEFORE+1)) "$LOG" | tail -80
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
|
||||
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
|
||||
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- key boot log lines ---"
|
||||
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
|
||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||
curl -sS --fail --max-time 5 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
--data "$payload" \
|
||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
|
||||
'''
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
set +e
|
||||
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
|
||||
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
|
||||
curl -sS --fail --max-time 5 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
--data "$payload" \
|
||||
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
triggers {
|
||||
pollSCM('H/10 * * * *')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
JAVA_HOME = '/apps/opts/jdk8'
|
||||
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
NODE_HOME = '/apps/opts/node-v24'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout dependencies') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
cd "$WORKSPACE/.."
|
||||
|
||||
if [ ! -d elink-portal-common/.git ]; then
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||
elink-portal-common
|
||||
else
|
||||
git -C elink-portal-common fetch --depth=1 origin master
|
||||
git -C elink-portal-common reset --hard origin/master
|
||||
git -C elink-portal-common clean -fdx
|
||||
fi
|
||||
|
||||
mkdir -p eapim-online
|
||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||
eapim-online/elink-online-core-jpa
|
||||
else
|
||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
java -version
|
||||
gradle --version
|
||||
node --version || true
|
||||
npm --version || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'gradle clean test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build WAR') {
|
||||
steps {
|
||||
sh 'gradle build -x test --no-daemon'
|
||||
}
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
set -eu
|
||||
cd build/libs
|
||||
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
|
||||
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
|
||||
'''
|
||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
||||
|
||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
||||
|
||||
|
||||
echo "========================"
|
||||
java -version
|
||||
javac -version
|
||||
echo "========================"
|
||||
gradle --version
|
||||
echo "========================"
|
||||
|
||||
gradle clean build -x test --no-daemon
|
||||
@@ -0,0 +1,3 @@
|
||||
./gradlew bootWar
|
||||
docker build -t eactive-portal .
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
|
||||
# Unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
STATIC_DIR="/app/apim/deploy/static"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$STATIC_DIR" ]; then
|
||||
echo "Backing up existing static directory..."
|
||||
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
mkdir -p $STATIC_DIR
|
||||
|
||||
# unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
mv $DEST_DIR/static/* $STATIC_DIR/
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
|
||||
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
|
||||
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
|
||||
|
||||
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
|
||||
|
||||
|
||||
# gradle 명령을 대체합니다.
|
||||
command gradle $@
|
||||
@@ -1,36 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE PTL_USER_SESSION
|
||||
(
|
||||
SESSION_ID VARCHAR2(128) NOT NULL,
|
||||
USER_ID VARCHAR2(36) NOT NULL,
|
||||
LOGIN_ID VARCHAR2(200) NOT NULL,
|
||||
LOGIN_TIME TIMESTAMP(6) NOT NULL,
|
||||
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
|
||||
IP_ADDRESS VARCHAR2(45),
|
||||
USER_AGENT VARCHAR2(500),
|
||||
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
|
||||
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
|
||||
);
|
||||
|
||||
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
|
||||
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
|
||||
|
||||
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
|
||||
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
|
||||
|
||||
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
|
||||
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
|
||||
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
|
||||
-- COMMIT;
|
||||
+1
-1
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
|
||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||
|
||||
// 성공 메시지 추가
|
||||
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
|
||||
|
||||
httpServletRequest.getSession().removeAttribute("previousPage");
|
||||
return "redirect:/partnership";
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
|
||||
|
||||
FileInfo file = null;
|
||||
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
try {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||
|
||||
@@ -25,7 +25,7 @@ public class InquiryDTO {
|
||||
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
||||
private String inquiryDetail;
|
||||
|
||||
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
private String inquiryStatus;
|
||||
|
||||
private String inquirerName;
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.session.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/session")
|
||||
@RequiredArgsConstructor
|
||||
public class SessionApiController {
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
/**
|
||||
* 로그인 전 중복 세션 확인
|
||||
*/
|
||||
@PostMapping("/check-duplicate")
|
||||
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
|
||||
|
||||
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
|
||||
|
||||
if (activeSession.isPresent()) {
|
||||
UserSession session = activeSession.get();
|
||||
result.put("duplicateSession", true);
|
||||
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
|
||||
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
|
||||
} else {
|
||||
result.put("duplicateSession", false);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 상태 폴링 (인증 필요)
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
HttpSession httpSession = request.getSession(false);
|
||||
|
||||
if (httpSession == null) {
|
||||
result.put("valid", false);
|
||||
result.put("remainingSeconds", 0);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
String sessionId = httpSession.getId();
|
||||
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
|
||||
|
||||
if (forceLoggedOut) {
|
||||
result.put("valid", false);
|
||||
result.put("remainingSeconds", 0);
|
||||
} else {
|
||||
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||
result.put("valid", remaining > 0);
|
||||
result.put("remainingSeconds", remaining);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하트비트 - lastAccessTime 갱신 (세션 연장)
|
||||
*/
|
||||
@PostMapping("/heartbeat")
|
||||
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
HttpSession httpSession = request.getSession(false);
|
||||
|
||||
if (httpSession == null) {
|
||||
result.put("remainingSeconds", 0);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
String sessionId = httpSession.getId();
|
||||
userSessionService.updateLastAccessTime(sessionId);
|
||||
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||
result.put("remainingSeconds", remaining);
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||
* 예: 192.168.240.178 → 192.168.***.178
|
||||
*/
|
||||
private String maskIpAddress(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return "알 수 없음";
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length == 4) {
|
||||
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
||||
}
|
||||
// IPv6 등 다른 형식은 일부만 표시
|
||||
if (ip.length() > 8) {
|
||||
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
||||
}
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.session.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
|
||||
*
|
||||
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
|
||||
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
|
||||
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Entity
|
||||
@Table(name = "PTL_USER_SESSION")
|
||||
public class UserSession {
|
||||
|
||||
@Id
|
||||
@Column(name = "SESSION_ID", length = 128, nullable = false)
|
||||
private String sessionId;
|
||||
|
||||
@Column(name = "USER_ID", length = 36, nullable = false)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "LOGIN_ID", length = 200, nullable = false)
|
||||
private String loginId;
|
||||
|
||||
@Column(name = "LOGIN_TIME", nullable = false)
|
||||
private LocalDateTime loginTime;
|
||||
|
||||
@Column(name = "LAST_ACCESS_TIME", nullable = false)
|
||||
private LocalDateTime lastAccessTime;
|
||||
|
||||
@Column(name = "IP_ADDRESS", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "USER_AGENT", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
|
||||
@Builder.Default
|
||||
private String forceLogout = "N";
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.session.filter;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
|
||||
*
|
||||
* <ol>
|
||||
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
|
||||
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
|
||||
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SessionValidationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
|
||||
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
|
||||
|
||||
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
|
||||
"/api/session/",
|
||||
"/login",
|
||||
"/actionLogin.do",
|
||||
"/actionLogout.do",
|
||||
"/css/",
|
||||
"/js/",
|
||||
"/img/",
|
||||
"/plugins/",
|
||||
"/fonts/",
|
||||
"/favicon.ico",
|
||||
"/error",
|
||||
"/health"
|
||||
);
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty()) {
|
||||
path = path.substring(contextPath.length());
|
||||
}
|
||||
|
||||
// 제외 경로 체크
|
||||
if (isExcludedPath(path)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증되지 않은 요청은 통과
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
HttpSession httpSession = request.getSession(false);
|
||||
if (httpSession == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String sessionId = httpSession.getId();
|
||||
|
||||
// 1. 강제 로그아웃 체크 (중복 로그인)
|
||||
if (userSessionService.isForceLoggedOut(sessionId)) {
|
||||
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
|
||||
userSessionService.removeSession(sessionId);
|
||||
httpSession.invalidate();
|
||||
SecurityContextHolder.clearContext();
|
||||
response.sendRedirect(contextPath + "/login?forceLogout=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
|
||||
long remaining = userSessionService.getRemainingSeconds(sessionId);
|
||||
if (remaining <= 0) {
|
||||
userSessionService.removeSession(sessionId);
|
||||
httpSession.invalidate();
|
||||
SecurityContextHolder.clearContext();
|
||||
response.sendRedirect(contextPath + "/login?expired=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. lastAccessTime 갱신 (60초 스로틀링)
|
||||
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
|
||||
long now = System.currentTimeMillis();
|
||||
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
|
||||
userSessionService.updateLastAccessTime(sessionId);
|
||||
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean isExcludedPath(String path) {
|
||||
for (String prefix : EXCLUDED_PREFIXES) {
|
||||
if (path.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.session.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
|
||||
|
||||
List<UserSession> findByLoginId(String loginId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
|
||||
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
|
||||
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
|
||||
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.session.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserSessionService {
|
||||
|
||||
private static final String PROPERTY_GROUP = "Portal";
|
||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||
|
||||
private final UserSessionRepository userSessionRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<UserSession> getActiveSession(String loginId) {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
|
||||
|
||||
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
|
||||
return sessions.stream()
|
||||
.filter(s -> "N".equals(s.getForceLogout()))
|
||||
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 세션 등록
|
||||
*/
|
||||
@Transactional
|
||||
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
|
||||
UserSession session = UserSession.builder()
|
||||
.sessionId(sessionId)
|
||||
.userId(userId)
|
||||
.loginId(loginId)
|
||||
.loginTime(LocalDateTime.now())
|
||||
.lastAccessTime(LocalDateTime.now())
|
||||
.ipAddress(ipAddress)
|
||||
.userAgent(truncate(userAgent, 500))
|
||||
.forceLogout("N")
|
||||
.build();
|
||||
userSessionRepository.save(session);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
|
||||
*/
|
||||
@Transactional
|
||||
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||
if (count > 0) {
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세션의 강제 로그아웃 여부 확인
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isForceLoggedOut(String sessionId) {
|
||||
return userSessionRepository.findById(sessionId)
|
||||
.map(s -> "Y".equals(s.getForceLogout()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 삭제
|
||||
*/
|
||||
@Transactional
|
||||
public void removeSession(String sessionId) {
|
||||
if (userSessionRepository.existsById(sessionId)) {
|
||||
userSessionRepository.deleteById(sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 접근 시간 갱신 (세션 연장)
|
||||
*/
|
||||
@Transactional
|
||||
public void updateLastAccessTime(String sessionId) {
|
||||
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
||||
*/
|
||||
public int getSessionTimeoutMinutes() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP,
|
||||
PROPERTY_NAME,
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
"세션 타임아웃 시간 (분)"
|
||||
);
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
|
||||
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long getRemainingSeconds(String sessionId) {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
return userSessionRepository.findById(sessionId)
|
||||
.map(s -> {
|
||||
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
|
||||
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
|
||||
return Math.max(0, remaining);
|
||||
})
|
||||
.orElse(0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진).
|
||||
*/
|
||||
@Scheduled(fixedRate = 300000)
|
||||
@Transactional
|
||||
public void cleanupExpiredSessions() {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2);
|
||||
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
|
||||
@ControllerAdvice
|
||||
public class GlobalControllerAdvice {
|
||||
@@ -22,9 +21,6 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private PortalProperties portalProperties;
|
||||
|
||||
@Autowired
|
||||
private UserSessionService userSessionService;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -62,12 +58,4 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
return userSessionService.getSessionTimeoutMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 문자열 마스킹 처리를 위한 유틸리티 클래스
|
||||
*
|
||||
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
|
||||
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
|
||||
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
|
||||
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
|
||||
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-1234→010-12**-12**)
|
||||
* </pre>
|
||||
*/
|
||||
public class StringMaskingUtil {
|
||||
|
||||
private static final char MASK = '*';
|
||||
|
||||
// ID 비교
|
||||
private static boolean isOwner(String ownerId, String currentUserId) {
|
||||
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
|
||||
@@ -35,15 +21,8 @@ public class StringMaskingUtil {
|
||||
return name;
|
||||
}
|
||||
|
||||
int len = name.length();
|
||||
if (len == 1) {
|
||||
return name;
|
||||
}
|
||||
if (len == 2) {
|
||||
return name.charAt(0) + String.valueOf(MASK);
|
||||
}
|
||||
// 3자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
|
||||
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
|
||||
return name.length() > 1 ?
|
||||
name.substring(0, name.length() - 1) + "*" : name;
|
||||
}
|
||||
|
||||
// 이메일 마스킹 처리
|
||||
@@ -52,32 +31,32 @@ public class StringMaskingUtil {
|
||||
return email;
|
||||
}
|
||||
|
||||
int atIdx = email.indexOf('@');
|
||||
String local = email.substring(0, atIdx);
|
||||
String domain = email.substring(atIdx);
|
||||
|
||||
if (local.length() <= 4) {
|
||||
return stars(local.length()) + domain;
|
||||
String[] parts = email.split("@", 2);
|
||||
if (parts.length != 2) {
|
||||
return email;
|
||||
}
|
||||
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
|
||||
|
||||
String localPart = parts[0];
|
||||
String maskedLocal;
|
||||
|
||||
if (localPart.length() <= 2) {
|
||||
maskedLocal = "***";
|
||||
} else {
|
||||
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
|
||||
}
|
||||
|
||||
return maskedLocal + "@" + parts[1];
|
||||
}
|
||||
|
||||
// 휴대폰/전화/FAX 번호 마스킹 처리
|
||||
// 휴대폰 번호 마스킹 처리
|
||||
public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
|
||||
if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
|
||||
return number;
|
||||
}
|
||||
|
||||
boolean hasHyphen = number.contains("-");
|
||||
String normalized = hasHyphen ? number : normalizePhoneNumber(number);
|
||||
String[] parts = normalized.split("-");
|
||||
if (parts.length == 3) {
|
||||
String m0 = parts[0];
|
||||
String m1 = maskSegmentTail(parts[1], 2);
|
||||
String m2 = maskSegmentTail(parts[2], 2);
|
||||
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
|
||||
}
|
||||
return number;
|
||||
String[] parts = number.split("-", 3);
|
||||
return parts.length == 3 ?
|
||||
parts[0] + "-***-" + parts[2] : number;
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
@@ -92,41 +71,4 @@ public class StringMaskingUtil {
|
||||
public static String maskMobileNumber(String number) {
|
||||
return maskMobileNumber(number, null, null);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// private helpers
|
||||
// =========================================================================
|
||||
|
||||
private static String stars(int count) {
|
||||
if (count <= 0) return "";
|
||||
char[] arr = new char[count];
|
||||
Arrays.fill(arr, MASK);
|
||||
return new String(arr);
|
||||
}
|
||||
|
||||
private static String maskSegmentTail(String segment, int count) {
|
||||
if (segment.length() <= count) return stars(segment.length());
|
||||
return segment.substring(0, segment.length() - count) + stars(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
|
||||
* <pre>
|
||||
* 02 + 7자리 → 02-XXX-XXXX
|
||||
* 02 + 8자리 → 02-XXXX-XXXX
|
||||
* 0XX + 7자리 → 0XX-XXX-XXXX
|
||||
* 0XX + 8자리 → 0XX-XXXX-XXXX
|
||||
* </pre>
|
||||
*/
|
||||
private static String normalizePhoneNumber(String digits) {
|
||||
int len = digits.length();
|
||||
if (digits.startsWith("02")) {
|
||||
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
|
||||
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
|
||||
} else if (digits.startsWith("0")) {
|
||||
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
|
||||
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
@@ -50,7 +49,6 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -114,12 +112,6 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -11,8 +10,9 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.session.SessionRegistry;
|
||||
import org.springframework.security.core.session.SessionRegistryImpl;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
@@ -33,30 +33,15 @@ public class PortalConfigSecurity {
|
||||
|
||||
private final PortalLogoutSuccessHandler logoutSuccessHandler;
|
||||
|
||||
private final SessionValidationFilter sessionValidationFilter;
|
||||
|
||||
@Autowired
|
||||
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
|
||||
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
|
||||
PortalAuthenticationManager portalAuthenticationManager,
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler,
|
||||
SessionValidationFilter sessionValidationFilter) {
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler) {
|
||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||
this.portalAuthenticationManager = portalAuthenticationManager;
|
||||
this.logoutSuccessHandler = logoutSuccessHandler;
|
||||
this.sessionValidationFilter = sessionValidationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
|
||||
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
|
||||
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -94,15 +79,17 @@ public class PortalConfigSecurity {
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
)
|
||||
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1) // Allow only one session per user
|
||||
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
|
||||
.expiredUrl("/login?expired") // Redirect when session expires
|
||||
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
|
||||
.and()
|
||||
.sessionFixation()
|
||||
.changeSessionId()
|
||||
)
|
||||
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
);
|
||||
|
||||
return http.build();
|
||||
} catch (Exception e) {
|
||||
@@ -110,6 +97,11 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SessionRegistry sessionRegistry() {
|
||||
return new SessionRegistryImpl();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public HttpSessionEventPublisher httpSessionEventPublisher() {
|
||||
return new HttpSessionEventPublisher();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
@@ -27,21 +26,12 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
|
||||
this.userSessionService = userSessionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
HttpSession session = request.getSession(false);
|
||||
|
||||
if (session != null) {
|
||||
// DB 세션 레코드 정리 (중복세션 오탐 방지)
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.djb.community.qna.constant;
|
||||
public final class DjbInquiryStatus {
|
||||
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String RESPONDED = "RESPONDED";
|
||||
public static final String IN_PROGRESS = "IN_PROGRESS";
|
||||
public static final String CLOSED = "CLOSED";
|
||||
|
||||
private DjbInquiryStatus() {
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="NEED_FIX" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/need-fix.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/backup/need-fix.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>500MB</maxFileSize>
|
||||
<maxHistory>90</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/http-session.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
@@ -54,6 +66,10 @@
|
||||
</appender>
|
||||
|
||||
|
||||
<logger name="eapim.portal.needfix" level="INFO" additivity="false">
|
||||
<appender-ref ref="NEED_FIX" />
|
||||
</logger>
|
||||
|
||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
||||
<appender-ref ref="HTTP_SESSION" />
|
||||
</logger>
|
||||
@@ -80,4 +96,51 @@
|
||||
<springProfile name="stage,dev">
|
||||
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="gf63">
|
||||
<logger name="sun.rmi" level="INFO"/>
|
||||
|
||||
<logger name="javax" level="INFO"/>
|
||||
|
||||
<logger name="jdk.event.security" level="INFO"/>
|
||||
|
||||
<logger name="com.zaxxer.hikari" level="INFO"/>
|
||||
<logger name="com.atomikos" level="INFO"/>
|
||||
|
||||
<logger name="org.apache" level="INFO"/>
|
||||
<logger name="org.codehaus.groovy.vmplugin.VMPluginFactory" level="INFO"/>
|
||||
<!-- <logger name="org.hibernate" level="INFO" additivity="false"/>-->
|
||||
<logger name="org.thymeleaf.TemplateEngine" level="INFO"/>
|
||||
<!-- <logger name="org.springframework" level="INFO" />-->
|
||||
<logger name="_org.springframework.web" level="INFO" />
|
||||
|
||||
|
||||
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
|
||||
<appender-ref ref="HIBERNATE_APPENDER" />
|
||||
</logger>
|
||||
|
||||
<logger name="com.eactive.eapim" level="DEBUG"/>
|
||||
<logger name="kjb.safedb" level="DEBUG" additivity="false">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</logger>
|
||||
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<!-- <root level="DEBUG">-->
|
||||
<!-- <appender-ref ref="ROLLING"/>-->
|
||||
<!-- <appender-ref ref="CONSOLE"/>-->
|
||||
<!-- </root>-->
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -18,7 +18,7 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1A1A2E;
|
||||
background-color: #FFFFFF;
|
||||
@@ -87,33 +87,22 @@ section, summary {
|
||||
color: #1A1A2E;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
src: url("/font/NotoSansKR-VariableFont_wght.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
|
||||
h1, .h1,
|
||||
h2, .h2,
|
||||
h3, .h3,
|
||||
h4, .h4,
|
||||
h5, .h5,
|
||||
h6, .h6 {
|
||||
font-family: "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[class$=-title],
|
||||
[class*="-title "],
|
||||
[class$=__title],
|
||||
[class*="__title "] {
|
||||
font-family: "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h1, .h1 {
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
@@ -129,6 +118,7 @@ h1, .h1 {
|
||||
|
||||
h2, .h2 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
@@ -144,6 +134,7 @@ h2, .h2 {
|
||||
|
||||
h3, .h3 {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
@@ -154,16 +145,19 @@ h3, .h3 {
|
||||
|
||||
h4, .h4 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h5, .h5 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h6, .h6 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -575,72 +569,93 @@ hr {
|
||||
transform: translateX(200%) rotate(45deg);
|
||||
}
|
||||
|
||||
/* OneShinhan */
|
||||
@font-face {
|
||||
font-family: "Spoqa Han Sans Neo";
|
||||
font-weight: 700;
|
||||
src: local("Spoqa Han Sans Neo Bold"), url("/font/kjb/SpoqaHanSansNeo-Bold.woff2") format("woff2"), url("/font/kjb/SpoqaHanSansNeo-Bold.woff") format("woff"), url("/font/kjb/SpoqaHanSansNeo-Bold.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Spoqa Han Sans Neo";
|
||||
font-weight: 400;
|
||||
src: local("Spoqa Han Sans Neo Regular"), url("/font/kjb/SpoqaHanSansNeo-Regular.woff2") format("woff2"), url("/font/kjb/SpoqaHanSansNeo-Regular.woff") format("woff"), url("/font/kjb/SpoqaHanSansNeo-Regular.ttf") format("truetype");
|
||||
}
|
||||
/* OneShinhan — 웹폰트 서빙 (제주은행 inbank/css/font/ 원본을 정적 리소스로 호스팅)
|
||||
weight 300=Light / 400&500=Medium / 700=Bold */
|
||||
@font-face {
|
||||
font-family: "OneShinhan";
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("OneShinhan Light"), local("OneShinhan-Light"), url("/font/shinhan/OneShinhanLight.woff") format("woff");
|
||||
src: local("원신한 Light"), local("OneShinhan Light"), local("OneShinhan-Light"), url("/font/djb/OneShinhanLight.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OneShinhan";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("OneShinhan Medium"), local("OneShinhan-Medium"), url("/font/shinhan/OneShinhanMedium.woff") format("woff");
|
||||
src: local("원신한 Medium"), local("OneShinhan Medium"), local("OneShinhan-Medium"), url("/font/djb/OneShinhanMedium.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OneShinhan";
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("OneShinhan Medium"), local("OneShinhan-Medium"), url("/font/shinhan/OneShinhanMedium.woff") format("woff");
|
||||
src: local("원신한 Medium"), local("OneShinhan Medium"), local("OneShinhan-Medium"), url("/font/djb/OneShinhanMedium.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "OneShinhan";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("OneShinhan Bold"), local("OneShinhan-Bold"), url("/font/shinhan/OneShinhanBold.woff") format("woff");
|
||||
src: local("원신한 Bold"), local("OneShinhan Bold"), local("OneShinhan-Bold"), url("/font/djb/OneShinhanBold.woff") format("woff");
|
||||
}
|
||||
/* Spoqa Han Sans Neo */
|
||||
/* 한글 family name alias (변수에서 '원신한' 우선 매칭 시 동일 폰트 사용) */
|
||||
@font-face {
|
||||
font-family: "Spoqa Han Sans Neo";
|
||||
font-family: "원신한";
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
src: url("/font/djb/OneShinhanLight.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "원신한";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("Spoqa Han Sans Neo Regular"), url("/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansNeo-Regular.ttf") format("truetype");
|
||||
src: url("/font/djb/OneShinhanMedium.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Spoqa Han Sans Neo";
|
||||
font-family: "원신한";
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: url("/font/djb/OneShinhanMedium.woff") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "원신한";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("Spoqa Han Sans Neo Bold"), url("/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansNeo-Bold.ttf") format("truetype");
|
||||
src: url("/font/djb/OneShinhanBold.woff") format("woff");
|
||||
}
|
||||
/* SpoqaHanSans (Neo 미설치 환경 폴백) */
|
||||
/* SpoqaHanSans */
|
||||
@font-face {
|
||||
font-family: "SpoqaHanSans";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("Spoqa Han Sans Regular"), url("/font/spoqa-hans/SpoqaHanSansRegular.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansRegular.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansRegular.ttf") format("truetype");
|
||||
src: url("/font/kjb/SpoqaHanSansRegular.woff") format("woff"), url("/font/kjb/SpoqaHanSansRegular.woff2") format("woff2"), url("/font/kjb/SpoqaHanSansRegular.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SpoqaHanSans";
|
||||
font-family: "SpoqaHanSansBold";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local("Spoqa Han Sans Bold"), url("/font/spoqa-hans/SpoqaHanSansBold.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansBold.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansBold.ttf") format("truetype");
|
||||
src: url("/font/kjb/SpoqaHanSansBold.woff") format("woff"), url("/font/kjb/SpoqaHanSansBold.woff2") format("woff2"), url("/font/kjb/SpoqaHanSansBold.ttf") format("truetype");
|
||||
}
|
||||
/* Noto Sans KR (Variable) */
|
||||
/* HGGGothicssi */
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/font/noto-sans/NotoSansKR-VariableFont_wght.ttf") format("truetype");
|
||||
font-family: "HGGGothicssi";
|
||||
font-weight: 300;
|
||||
src: url("/font/kjb/HGGGothicssi40g.woff") format("woff"), url("/font/kjb/HGGGothicssi40g.woff2") format("woff2"), url("/font/kjb/HGGGothicssi40g.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "HGGGothicssi";
|
||||
font-weight: 400;
|
||||
src: url("/font/kjb/HGGGothicssi60g.woff") format("woff"), url("/font/kjb/HGGGothicssi60g.woff2") format("woff2"), url("/font/kjb/HGGGothicssi60g.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "HGGGothicssi";
|
||||
font-weight: 700;
|
||||
src: url("/font/kjb/HGGGothicssi80g.woff") format("woff"), url("/font/kjb/HGGGothicssi80g.woff2") format("woff2"), url("/font/kjb/HGGGothicssi80g.ttf") format("truetype");
|
||||
}
|
||||
:root {
|
||||
--primary-blue: #4B9BFF;
|
||||
@@ -907,7 +922,7 @@ body.design-survey-active .global-header {
|
||||
object-fit: contain;
|
||||
}
|
||||
.mobile-left .mobile-logo-text {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: clamp(14px, 3.73vw, 18px);
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -1027,7 +1042,7 @@ body.design-survey-active .global-header {
|
||||
object-fit: contain;
|
||||
}
|
||||
.mobile-drawer .drawer-header .drawer-header-left .drawer-logo-text {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: clamp(14px, 3.73vw, 18px);
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -1089,7 +1104,7 @@ body.design-survey-active .global-header {
|
||||
height: 56px;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .welcome-login-prompt {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -1098,7 +1113,7 @@ body.design-survey-active .global-header {
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .welcome-text {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
@@ -1120,7 +1135,7 @@ body.design-survey-active .global-header {
|
||||
height: 40px;
|
||||
background: #e5e7eb;
|
||||
color: #5f666c;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
@@ -1138,7 +1153,7 @@ body.design-survey-active .global-header {
|
||||
height: 40px;
|
||||
background: #0049b4;
|
||||
color: #ffffff;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
@@ -1165,7 +1180,7 @@ body.design-survey-active .global-header {
|
||||
gap: 8px;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated .welcome-user-name {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -1198,7 +1213,7 @@ body.design-survey-active .global-header {
|
||||
height: 16px;
|
||||
}
|
||||
.mobile-drawer .drawer-footer .drawer-logout-btn span {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #0049b4;
|
||||
@@ -1230,7 +1245,7 @@ body.design-survey-active .global-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
@@ -1249,7 +1264,7 @@ body.design-survey-active .global-header {
|
||||
.mobile-drawer .drawer-nav .drawer-menu-item .drawer-submenu li a {
|
||||
display: block;
|
||||
padding: 8px 16px 8px 57px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
@@ -1532,7 +1547,7 @@ body.design-survey-active .global-header {
|
||||
display: block;
|
||||
}
|
||||
.header-user-info .user-identity .user-name {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #515961;
|
||||
@@ -1544,7 +1559,7 @@ body.design-survey-active .global-header {
|
||||
line-height: 1;
|
||||
}
|
||||
.header-user-info .header-link {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #515961;
|
||||
@@ -1665,7 +1680,7 @@ body.design-survey-active .global-header {
|
||||
gap: 8px;
|
||||
}
|
||||
.footer-left .footer-links .footer-link {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #5F666C;
|
||||
@@ -1682,7 +1697,7 @@ body.design-survey-active .global-header {
|
||||
user-select: none;
|
||||
}
|
||||
.footer-left .footer-copyright {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9CA3AF;
|
||||
@@ -1706,7 +1721,7 @@ body.design-survey-active .global-header {
|
||||
width: 240px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #5F666C;
|
||||
@@ -1735,7 +1750,7 @@ body.design-survey-active .global-header {
|
||||
}
|
||||
}
|
||||
.footer-right .footer-contact {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1F2937;
|
||||
@@ -3281,7 +3296,9 @@ body.design-survey-active .global-header {
|
||||
}
|
||||
}
|
||||
.common-title-bar .common-title, .app-list-title-section .common-title, .register-title-bar .common-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -3342,7 +3359,7 @@ body.design-survey-active .global-header {
|
||||
justify-content: center;
|
||||
padding: 5px 14px;
|
||||
border-radius: 40px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -3453,14 +3470,14 @@ body.design-survey-active .global-header {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.apikey-empty-state h3, .app-list-empty h3, .empty-state h3, .empty-api-state h3 {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.apikey-empty-state p, .app-list-empty p, .empty-state p, .empty-api-state p {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
color: #6e7780;
|
||||
margin: 0;
|
||||
@@ -3474,7 +3491,7 @@ body.design-survey-active .global-header {
|
||||
height: 60px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -3764,7 +3781,7 @@ body.design-survey-active .global-header {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1A1A2E;
|
||||
background: #FFFFFF;
|
||||
border: 2px solid #E2E8F0;
|
||||
@@ -4429,7 +4446,7 @@ select.form-control {
|
||||
padding: 0 44px 0 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #1A1A2E;
|
||||
@@ -4758,7 +4775,7 @@ select.form-control {
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -5083,7 +5100,7 @@ select.form-control {
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
@@ -5108,7 +5125,7 @@ select.form-control {
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
.user-profile-dropdown .user-profile-btn .user-name {
|
||||
@@ -5310,37 +5327,6 @@ select.form-control {
|
||||
right: -20px;
|
||||
}
|
||||
}
|
||||
.session-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
}
|
||||
.session-timer .session-timer-icon {
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
}
|
||||
.session-timer .session-timer-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.session-timer .session-timer-text.session-timer-warning {
|
||||
color: #e03131;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-timer-mobile {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-timer-mobile .session-timer-text {
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
@@ -6476,7 +6462,7 @@ select.form-control {
|
||||
justify-content: center;
|
||||
min-width: 10px;
|
||||
height: 21px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #5f666c;
|
||||
@@ -6577,7 +6563,7 @@ select.form-control {
|
||||
}
|
||||
}
|
||||
.breadcrumb-item {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
@@ -6656,7 +6642,7 @@ select.form-control {
|
||||
}
|
||||
}
|
||||
.breadcrumb-nav .breadcrumb-list-item a {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
@@ -6726,6 +6712,7 @@ select.form-control {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #E65100;
|
||||
font-weight: 500;
|
||||
}
|
||||
.test-env-notice__description {
|
||||
margin: 4px 0 0 0;
|
||||
@@ -7087,7 +7074,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
|
||||
.hero-text .hero-subtitle {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
line-height: 99.94%;
|
||||
@@ -7109,7 +7096,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.hero-text .hero-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 46px;
|
||||
font-weight: 700;
|
||||
line-height: 99.94%;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -7137,7 +7126,7 @@ button.djb-comment-submit:disabled {
|
||||
padding: 10px;
|
||||
background: #0049B4;
|
||||
color: #FFFFFF;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
@@ -7436,7 +7425,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-search-section .search-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -7508,7 +7499,7 @@ button.djb-comment-submit:disabled {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
@@ -7604,7 +7595,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-search-section .hashtag-label {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
@@ -7635,7 +7626,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-search-section .hashtag-link {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -7659,7 +7650,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-search-section .hashtag-separator {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
color: #000000;
|
||||
user-select: none;
|
||||
@@ -7733,7 +7724,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-showcase .section-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -7751,7 +7744,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-showcase .section-subtitle {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
@@ -7775,7 +7768,7 @@ button.djb-comment-submit:disabled {
|
||||
justify-content: center;
|
||||
gap: 21px;
|
||||
padding: 12px 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
@@ -7889,7 +7882,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-showcase .card-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.67;
|
||||
color: #2A2A2A;
|
||||
margin: 0 0 20px 0;
|
||||
@@ -7903,7 +7898,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-showcase .card-description {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1.43;
|
||||
@@ -8013,7 +8008,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.info-section .info-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
margin: 0 0 24px 0;
|
||||
@@ -8049,7 +8046,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.info-section .info-description {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
@@ -8092,7 +8089,7 @@ button.djb-comment-submit:disabled {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px 52px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 56px;
|
||||
@@ -8249,7 +8246,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.support-center .section-header .section-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
margin: 0 0 24px 0;
|
||||
@@ -8381,7 +8380,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.support-center .support-card .card-content h3 {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -8401,7 +8400,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.support-center .support-card .card-content p {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #515961;
|
||||
@@ -8612,6 +8611,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-stats-section .section-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -8715,7 +8715,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-stats-section .stat-card .stat-label {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
@@ -8756,7 +8756,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.api-stats-section .stat-card .stat-number {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
color: #EFDCB2;
|
||||
@@ -8860,7 +8860,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.signup-cta-section .cta-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
@@ -8888,7 +8890,7 @@ button.djb-comment-submit:disabled {
|
||||
width: 178px;
|
||||
background: #008ae2;
|
||||
color: #FFFFFF;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
@@ -9814,7 +9816,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
|
||||
.login-message {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -9837,7 +9839,7 @@ button.djb-comment-submit:disabled {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
padding: 0 32px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #5F666C;
|
||||
@@ -9892,7 +9894,7 @@ button.djb-comment-submit:disabled {
|
||||
border-width: 0 2px 2px 0;
|
||||
}
|
||||
.login-form .form-checkbox label {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -9906,7 +9908,7 @@ button.djb-comment-submit:disabled {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding: 10px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -9937,7 +9939,7 @@ button.djb-comment-submit:disabled {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.login-links a {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -10159,7 +10161,7 @@ button.djb-comment-submit:disabled {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 24px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #8c959f;
|
||||
@@ -10196,7 +10198,7 @@ button.djb-comment-submit:disabled {
|
||||
margin-bottom: 20px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -10256,7 +10258,7 @@ button.djb-comment-submit:disabled {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 170px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
@@ -10276,7 +10278,7 @@ button.djb-comment-submit:disabled {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
@@ -10308,7 +10310,7 @@ button.djb-comment-submit:disabled {
|
||||
.account-recovery-form .form-select {
|
||||
height: 40px;
|
||||
padding: 0 32px 0 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #515151;
|
||||
@@ -10414,7 +10416,7 @@ button.djb-comment-submit:disabled {
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #ed5b5b;
|
||||
@@ -10432,7 +10434,7 @@ button.djb-comment-submit:disabled {
|
||||
width: 140px;
|
||||
height: 40px;
|
||||
padding: 8px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -10482,7 +10484,7 @@ button.djb-comment-submit:disabled {
|
||||
width: 160px;
|
||||
height: 40px;
|
||||
padding: 8px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
@@ -10610,7 +10612,9 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.account-recovery-result .result-header .result-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -10620,7 +10624,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.account-recovery-result .result-header .result-description {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #5f666c;
|
||||
@@ -10669,7 +10673,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.found-users-list .found-user-item .user-email {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
@@ -10680,7 +10684,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
}
|
||||
.found-users-list .found-user-item .user-date {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #8c959f;
|
||||
@@ -10700,7 +10704,7 @@ button.djb-comment-submit:disabled {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.result-info-box .info-text {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #5f666c;
|
||||
@@ -10945,7 +10949,7 @@ button.djb-comment-submit:disabled {
|
||||
}
|
||||
|
||||
.signup-message {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -10971,7 +10975,7 @@ button.djb-comment-submit:disabled {
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -11015,7 +11019,7 @@ button.djb-comment-submit:disabled {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.signup-navigation .signup-nav-link {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
@@ -13685,7 +13689,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
|
||||
.app-list-title {
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -13809,7 +13815,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
|
||||
.app-list-name {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
@@ -13825,7 +13831,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
|
||||
.app-list-description {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #6e7780;
|
||||
@@ -14087,7 +14093,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
background: #E5E7EB;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #5F666C;
|
||||
@@ -14647,7 +14653,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
background: #E5E7EB;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #5F666C;
|
||||
@@ -14680,7 +14686,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
background: #0049b4;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -14713,7 +14719,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
background: #DC3545;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -14777,7 +14783,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.file-upload-inline .file-display-text {
|
||||
flex: 1;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
color: #94A3B8;
|
||||
overflow: hidden;
|
||||
@@ -14820,7 +14826,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
background: #3ba4ed;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
@@ -15506,7 +15512,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
resize: vertical;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -16902,9 +16908,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.inquiry-status-badge--pending {
|
||||
background-color: #8c959f;
|
||||
}
|
||||
.inquiry-status-badge--closed {
|
||||
background-color: #4a5560;
|
||||
}
|
||||
|
||||
.inquiry-actions {
|
||||
display: flex;
|
||||
@@ -17680,7 +17683,7 @@ body.commission-print-page {
|
||||
font-size: 12px;
|
||||
width: 600px;
|
||||
margin: 90px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body.commission-print-page div {
|
||||
position: relative;
|
||||
@@ -17858,7 +17861,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #dadada;
|
||||
border-radius: 12px;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #212529;
|
||||
@@ -18153,6 +18156,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -18160,6 +18164,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
.service-intro__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -18182,6 +18187,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
.service-intro__section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.service-intro__section-body {
|
||||
@@ -18236,6 +18242,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
}
|
||||
.signup-guide__title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #0049b4;
|
||||
margin: 0 0 22px 0;
|
||||
@@ -18654,6 +18661,7 @@ body.commission-print-page .btn-primary:hover {
|
||||
padding: 20px;
|
||||
background-color: #FFFFFF;
|
||||
color: #1A1A2E;
|
||||
font-family: "원신한", "OneShinhan", "Spoqa Han Sans Neo", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
|
||||
// Color Palette - Vibrant and Modern
|
||||
// Primary colors
|
||||
$primary-blue: #0049b4; // Vibrant blue
|
||||
$primary-blue: #0049b4; // 광주은행 블루
|
||||
$secondary-blue: #c3dfea; // Deep blue
|
||||
$accent-cyan: #00D4FF; // Sky blue accent
|
||||
$accent-yellow: #FFD93D; // Yellow accent
|
||||
@@ -35,8 +35,7 @@ $shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
$shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
||||
|
||||
// Typography
|
||||
$font-family-primary: 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-family-heading: 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-family-primary: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
$font-family-mono: 'Fira Code', 'Courier New', monospace;
|
||||
|
||||
// Font sizes
|
||||
|
||||
@@ -1,97 +1,130 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webfont @font-face declarations
|
||||
// - OneShinhan : 제목/강조 (Light 300, Medium 400·500, Bold 700)
|
||||
// - Spoqa Han Sans Neo / SpoqaHanSans : 본문 (Regular 400, Bold 700)
|
||||
// - Noto Sans KR : 한글 폴백 (Variable 100–900)
|
||||
// -----------------------------------------------------------------------------
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
/* OneShinhan */
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 700;
|
||||
src: local('Spoqa Han Sans Neo Bold'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.ttf') format('truetype')
|
||||
;
|
||||
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 400;
|
||||
src: local('Spoqa Han Sans Neo Regular'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.ttf') format('truetype')
|
||||
;
|
||||
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
|
||||
}
|
||||
|
||||
/* OneShinhan — 웹폰트 서빙 (제주은행 inbank/css/font/ 원본을 정적 리소스로 호스팅)
|
||||
weight 300=Light / 400&500=Medium / 700=Bold */
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Light'),
|
||||
src: local('원신한 Light'),
|
||||
local('OneShinhan Light'),
|
||||
local('OneShinhan-Light'),
|
||||
url('/font/shinhan/OneShinhanLight.woff') format('woff');
|
||||
url('/font/djb/OneShinhanLight.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Medium'),
|
||||
src: local('원신한 Medium'),
|
||||
local('OneShinhan Medium'),
|
||||
local('OneShinhan-Medium'),
|
||||
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
|
||||
url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Medium'),
|
||||
src: local('원신한 Medium'),
|
||||
local('OneShinhan Medium'),
|
||||
local('OneShinhan-Medium'),
|
||||
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
|
||||
url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Bold'),
|
||||
src: local('원신한 Bold'),
|
||||
local('OneShinhan Bold'),
|
||||
local('OneShinhan-Bold'),
|
||||
url('/font/shinhan/OneShinhanBold.woff') format('woff');
|
||||
url('/font/djb/OneShinhanBold.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Spoqa Han Sans Neo */
|
||||
/* 한글 family name alias (변수에서 '원신한' 우선 매칭 시 동일 폰트 사용) */
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-family: '원신한';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanLight.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Neo Regular'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.ttf') format('truetype');
|
||||
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-family: '원신한';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Neo Bold'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.ttf') format('truetype');
|
||||
src: url('/font/djb/OneShinhanBold.woff') format('woff');
|
||||
}
|
||||
|
||||
/* SpoqaHanSans (Neo 미설치 환경 폴백) */
|
||||
|
||||
// KJB PIB
|
||||
/* SpoqaHanSans */
|
||||
@font-face {
|
||||
font-family: 'SpoqaHanSans';
|
||||
font-family:'SpoqaHanSans';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Regular'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.ttf') format('truetype');
|
||||
src:url('/font/kjb/SpoqaHanSansRegular.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansRegular.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansRegular.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'SpoqaHanSans';
|
||||
font-family:'SpoqaHanSansBold';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Bold'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
|
||||
src:url('/font/kjb/SpoqaHanSansBold.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansBold.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansBold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Noto Sans KR (Variable) */
|
||||
/* HGGGothicssi */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype');
|
||||
font-family:'HGGGothicssi';
|
||||
font-weight: 300;
|
||||
src:url('/font/kjb/HGGGothicssi40g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi40g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi40g.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'HGGGothicssi';
|
||||
font-weight: 400;
|
||||
src:url('/font/kjb/HGGGothicssi60g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi60g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi60g.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'HGGGothicssi';
|
||||
font-weight: 700;
|
||||
src:url('/font/kjb/HGGGothicssi80g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi80g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi80g.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@@ -6,38 +6,26 @@
|
||||
// Basic typography styles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Noto Sans KR Font Face
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
src: url('/font/NotoSansKR-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
// Body text
|
||||
body {
|
||||
font-family: $font-family-primary;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
// Headings — OneShinhan Medium
|
||||
h1, .h1,
|
||||
h2, .h2,
|
||||
h3, .h3,
|
||||
h4, .h4,
|
||||
h5, .h5,
|
||||
h6, .h6 {
|
||||
font-family: $font-family-heading;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
// "-title" / "__title" 으로 끝나는 클래스(.page-title, .section-title,
|
||||
// .row-cell--title, .oauth2-2legged__title …)도 사실상 제목 영역이므로
|
||||
// 동일하게 OneShinhan Medium 을 적용한다.
|
||||
[class$="-title"],
|
||||
[class*="-title "],
|
||||
[class$="__title"],
|
||||
[class*="__title "] {
|
||||
font-family: $font-family-heading;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
// Headings
|
||||
h1, .h1 {
|
||||
font-size: $font-size-5xl;
|
||||
font-weight: $font-weight-extrabold;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
@include respond-to('sm') {
|
||||
@@ -51,6 +39,7 @@ h1, .h1 {
|
||||
|
||||
h2, .h2 {
|
||||
font-size: $font-size-4xl;
|
||||
font-weight: $font-weight-bold;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
@include respond-to('sm') {
|
||||
@@ -64,6 +53,7 @@ h2, .h2 {
|
||||
|
||||
h3, .h3 {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
@include respond-to('sm') {
|
||||
@@ -73,16 +63,19 @@ h3, .h3 {
|
||||
|
||||
h4, .h4 {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
h5, .h5 {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
h6, .h6 {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ $apikey-border-color: #dadada;
|
||||
}
|
||||
|
||||
.common-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 22px;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Session Timer Component
|
||||
// 인증 사용자 헤더에 남은 세션 시간을 표시. 만료 임박(<=60s) 시 경고색.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.session-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
white-space: nowrap;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-gray;
|
||||
|
||||
.session-timer-icon {
|
||||
font-size: $font-size-xs;
|
||||
color: $text-gray;
|
||||
}
|
||||
|
||||
.session-timer-text {
|
||||
// 카운트다운 중 폭 흔들림 방지
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
|
||||
&.session-timer-warning {
|
||||
color: #e03131;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
|
||||
.session-timer-mobile {
|
||||
margin-right: $spacing-sm;
|
||||
font-size: $font-size-xs;
|
||||
|
||||
.session-timer-text {
|
||||
min-width: 34px;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #E65100;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__description {
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
@use 'components/cta' as *;
|
||||
@use 'components/password-popup' as *;
|
||||
@use 'components/header-auth' as *;
|
||||
@use 'components/session-timer' as *;
|
||||
@use 'components/tables' as *;
|
||||
@use 'components/accordion' as *;
|
||||
@use 'components/page-title-banner' as *;
|
||||
|
||||
@@ -569,7 +569,9 @@
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 12px;
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
}
|
||||
|
||||
.app-list-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 28px;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
|
||||
|
||||
@@ -150,7 +150,9 @@
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 46px;
|
||||
font-weight: 700;
|
||||
line-height: 99.94%;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -498,7 +500,9 @@
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -816,7 +820,9 @@
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
@@ -982,7 +988,9 @@
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.67;
|
||||
color: #2A2A2A;
|
||||
margin: 0 0 20px 0;
|
||||
@@ -1129,7 +1137,9 @@
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
@@ -1392,7 +1402,9 @@
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
margin: 0 0 $spacing-lg 0;
|
||||
@@ -1781,6 +1793,7 @@
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: $font-family-primary;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
|
||||
@@ -2048,7 +2061,9 @@
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
font-family: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -38,6 +39,7 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -127,6 +130,7 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
||||
|
||||
&__title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: $service-primary-blue;
|
||||
margin: 0 0 22px 0;
|
||||
@@ -685,6 +689,7 @@ $o2leg-err-fg: #a23b3b;
|
||||
padding: 20px;
|
||||
background-color: #FFFFFF;
|
||||
color: $o2leg-text-dark;
|
||||
font-family: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
@@ -384,11 +384,6 @@
|
||||
&--pending {
|
||||
background-color: #8c959f;
|
||||
}
|
||||
|
||||
// 종료 - 짙은 회색 배경
|
||||
&--closed {
|
||||
background-color: #4a5560;
|
||||
}
|
||||
}
|
||||
|
||||
// Inquiry Actions
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<div class="inquiry-detail-header">
|
||||
<div class="inquiry-header-top">
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||
th:classappend="${inquiry.inquiryStatus == 'IN_PROGRESS' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||
th:text="${inquiry.inquiryStatus == 'IN_PROGRESS' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||
답변대기
|
||||
</span>
|
||||
<div class="inquiry-header-right">
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Admin Response Section -->
|
||||
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}">
|
||||
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'IN_PROGRESS' or inquiry.inquiryStatus == 'CLOSED'}">
|
||||
<div class="inquiry-response-header">
|
||||
<span class="response-label">관리자 답변</span>
|
||||
<span class="response-date" th:text="${#temporals.format(inquiry.responseDate, 'yyyy.MM.dd')}">2025.11.02</span>
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||
th:classappend="${inquiry.inquiryStatus == 'IN_PROGRESS' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
|
||||
th:text="${inquiry.inquiryStatus == 'IN_PROGRESS' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
|
||||
답변대기
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>로그아웃되었습니다.</span>
|
||||
</div>
|
||||
<div th:if="${param.expired}" class="login-alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
|
||||
</div>
|
||||
<div th:if="${param.forceLogout}" class="login-alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
|
||||
@@ -115,36 +107,6 @@
|
||||
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
||||
}
|
||||
|
||||
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
||||
function checkDuplicateAndLogin(form) {
|
||||
var loginId = form.id.value;
|
||||
$.ajax({
|
||||
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
|
||||
type: 'POST',
|
||||
data: { loginId: loginId },
|
||||
success: function (data) {
|
||||
if (data && data.duplicateSession) {
|
||||
$('#loginLoading').removeClass('active');
|
||||
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
|
||||
+ '접속 시각: ' + data.loginTime + '<br><br>'
|
||||
+ '기존 접속을 해제하고 로그인하시겠습니까?';
|
||||
customPopups.showConfirm(msg, function (confirmed) {
|
||||
if (confirmed) {
|
||||
$('#loginLoading').addClass('active');
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
// 중복 체크 실패 시 로그인은 그대로 진행
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fnInit() {
|
||||
let form = document.getElementById('loginForm');
|
||||
if (form) {
|
||||
@@ -209,7 +171,7 @@
|
||||
$('#loginLoading').removeClass('active');
|
||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||
} else {
|
||||
checkDuplicateAndLogin(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
처음 만나는 DJBank API
|
||||
</a>
|
||||
<a href="#" class="action-btn action-btn-secondary">
|
||||
피드백/개선요청
|
||||
사업 제휴 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<li><a th:href="@{/portalnotice}">공지사항</a></li>
|
||||
<li><a th:href="@{/faq_list}">FAQ</a></li>
|
||||
<li><a th:href="@{/inquiry}">Q&A</a></li>
|
||||
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
|
||||
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -64,11 +64,6 @@
|
||||
<!-- Logout State (Authenticated) -->
|
||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||
<div class="header-user-info">
|
||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||
</span>
|
||||
<span class="divider">•</span>
|
||||
<div class="user-identity">
|
||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||
@@ -86,6 +81,7 @@
|
||||
<li sec:authorize="hasRole('ROLE_APP')">
|
||||
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
||||
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||
<a th:href="@{/commission/manage}"><i class="fas fa-money-bill"></i>과금 관리</a>
|
||||
</li>
|
||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||
@@ -107,10 +103,6 @@
|
||||
</div>
|
||||
|
||||
<div class="mobile-right">
|
||||
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text">--:--</span>
|
||||
</span>
|
||||
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
@@ -251,148 +243,6 @@
|
||||
|
||||
<!-- Mobile Menu & Dropdown JavaScript -->
|
||||
<th:block th:fragment="headerScript">
|
||||
<!-- 세션 타이머 / 유휴 자동 로그아웃 / 연장 확인 (인증 사용자만) -->
|
||||
<script sec:authorize="isAuthenticated()" th:inline="javascript">
|
||||
(function () {
|
||||
var STATUS_URL = /*[[@{/api/session/status}]]*/ '/api/session/status';
|
||||
var HEARTBEAT_URL = /*[[@{/api/session/heartbeat}]]*/ '/api/session/heartbeat';
|
||||
var EXPIRED_URL = /*[[@{/login?expired=true}]]*/ '/login?expired=true';
|
||||
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
||||
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
||||
|
||||
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
|
||||
var remainingSeconds = timeoutMinutes * 60;
|
||||
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
||||
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
||||
|
||||
// 데스크톱·모바일 타이머 텍스트를 모두 갱신 (id 대신 클래스 사용)
|
||||
var timerTexts = document.querySelectorAll('.session-timer-text');
|
||||
var countdownTimer = null;
|
||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||
var redirecting = false;
|
||||
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
return m ? decodeURIComponent(m.pop()) : '';
|
||||
}
|
||||
|
||||
function csrfHeaders() {
|
||||
var token = getCookie('XSRF-TOKEN');
|
||||
return token ? { 'X-XSRF-TOKEN': token } : {};
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
if (sec < 0) sec = 0;
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!timerTexts.length) return;
|
||||
var label = formatTime(remainingSeconds);
|
||||
var warn = remainingSeconds <= WARNING_SECONDS;
|
||||
for (var i = 0; i < timerTexts.length; i++) {
|
||||
timerTexts[i].textContent = label;
|
||||
if (warn) {
|
||||
timerTexts[i].classList.add('session-timer-warning');
|
||||
} else {
|
||||
timerTexts[i].classList.remove('session-timer-warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(url) {
|
||||
if (redirecting) return;
|
||||
redirecting = true;
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function extendSession() {
|
||||
$.ajax({
|
||||
url: HEARTBEAT_URL,
|
||||
type: 'POST',
|
||||
headers: csrfHeaders(),
|
||||
success: function (data) {
|
||||
if (data && data.remainingSeconds > 0) {
|
||||
remainingSeconds = data.remainingSeconds;
|
||||
promptShown = false;
|
||||
render();
|
||||
} else {
|
||||
goTo(EXPIRED_URL);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
// 연장 실패 시 만료로 처리
|
||||
goTo(EXPIRED_URL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showExtendConfirm() {
|
||||
if (typeof customPopups === 'undefined' || !customPopups.showConfirm) {
|
||||
return; // 팝업 미로딩 시 카운트다운만으로 만료 처리
|
||||
}
|
||||
var $yes = $('#customConfirmYesButton');
|
||||
var $no = $('#customConfirmNoButton');
|
||||
var prevYes = $yes.text();
|
||||
var prevNo = $no.text();
|
||||
$yes.text('연장하기');
|
||||
$no.text('로그아웃');
|
||||
customPopups.showConfirm('세션이 곧 만료됩니다. 로그인 상태를 연장하시겠습니까?', function (extend) {
|
||||
// 버튼 라벨 원복 (다른 confirm 팝업에 영향 없도록)
|
||||
$yes.text(prevYes);
|
||||
$no.text(prevNo);
|
||||
if (extend) {
|
||||
extendSession();
|
||||
} else {
|
||||
goTo(LOGOUT_URL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
remainingSeconds--;
|
||||
if (remainingSeconds <= 0) {
|
||||
goTo(EXPIRED_URL);
|
||||
return;
|
||||
}
|
||||
if (remainingSeconds <= WARNING_SECONDS && !promptShown) {
|
||||
promptShown = true;
|
||||
showExtendConfirm();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
$.ajax({
|
||||
url: STATUS_URL,
|
||||
type: 'GET',
|
||||
success: function (data) {
|
||||
if (!data || !data.valid) {
|
||||
goTo(FORCE_LOGOUT_URL);
|
||||
return;
|
||||
}
|
||||
remainingSeconds = data.remainingSeconds;
|
||||
if (remainingSeconds > WARNING_SECONDS) {
|
||||
promptShown = false; // 다른 탭/활동으로 연장된 경우 모달 재무장
|
||||
}
|
||||
render();
|
||||
},
|
||||
error: function () {
|
||||
// 폴링 실패 시 로컬 카운트다운 유지
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
render();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile Drawer
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* eapim-portal {@link StringMaskingUtil}가 보안아키텍처 표준 규칙으로 마스킹하며,
|
||||
* 소유자 본인 조회 시에는 마스킹하지 않는 동작을 검증한다.
|
||||
*/
|
||||
class StringMaskingUtilTest {
|
||||
|
||||
// --- 이름 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("이름 — 2자: 뒤 1자리")
|
||||
void maskName_2chars() {
|
||||
assertEquals("가*", StringMaskingUtil.maskName("가나"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이름 — 3자: 가운데 1자리")
|
||||
void maskName_3chars() {
|
||||
assertEquals("가*다", StringMaskingUtil.maskName("가나다"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이름 — 4자: 앞뒤 제외 가운데 전체")
|
||||
void maskName_4chars() {
|
||||
assertEquals("가**라", StringMaskingUtil.maskName("가나다라"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이름 — 5자: 앞뒤 제외 가운데 전체")
|
||||
void maskName_5chars() {
|
||||
assertEquals("가***마", StringMaskingUtil.maskName("가나다라마"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이름 — 1자/null/빈문자 방어")
|
||||
void maskName_oneAndNull() {
|
||||
assertEquals("가", StringMaskingUtil.maskName("가"));
|
||||
assertNull(StringMaskingUtil.maskName(null));
|
||||
assertEquals("", StringMaskingUtil.maskName(""));
|
||||
}
|
||||
|
||||
// --- 이메일 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("이메일 — 아이디 앞2·뒤2 마스킹")
|
||||
void maskEmail_normal() {
|
||||
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이메일 — 아이디 4자: 전체 마스킹")
|
||||
void maskEmail_fourChars() {
|
||||
assertEquals("****@test.com", StringMaskingUtil.maskEmail("test@test.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이메일 — 아이디 1자: 전체 마스킹")
|
||||
void maskEmail_oneChar() {
|
||||
assertEquals("*@123.com", StringMaskingUtil.maskEmail("1@123.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이메일 — @ 없으면 원본 / null·빈문자 방어")
|
||||
void maskEmail_invalidAndNull() {
|
||||
assertEquals("notanemail", StringMaskingUtil.maskEmail("notanemail"));
|
||||
assertNull(StringMaskingUtil.maskEmail(null));
|
||||
assertEquals("", StringMaskingUtil.maskEmail(""));
|
||||
}
|
||||
|
||||
// --- 휴대폰/전화 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("휴대폰 — 2·3번째 세그먼트 각 뒤 2자리")
|
||||
void maskMobileNumber_normal() {
|
||||
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234"));
|
||||
assertEquals("010-12**-56**", StringMaskingUtil.maskMobileNumber("010-1234-5678"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("휴대폰 — 하이픈 없는 11자리")
|
||||
void maskMobileNumber_noHyphen() {
|
||||
assertEquals("01012**56**", StringMaskingUtil.maskMobileNumber("01012345678"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("휴대폰 — null/빈문자 방어")
|
||||
void maskMobileNumber_null() {
|
||||
assertNull(StringMaskingUtil.maskMobileNumber(null));
|
||||
assertEquals("", StringMaskingUtil.maskMobileNumber(""));
|
||||
}
|
||||
|
||||
// --- owner 인지 (본인 조회 시 비마스킹) ---
|
||||
|
||||
@Test
|
||||
@DisplayName("owner — 본인 조회 시 마스킹하지 않음")
|
||||
void ownerAware_noMaskWhenOwner() {
|
||||
assertEquals("가나다", StringMaskingUtil.maskName("가나다", "user1", "user1"));
|
||||
assertEquals("test123@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user1"));
|
||||
assertEquals("010-1234-1234", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("owner — 타인 조회 시 마스킹")
|
||||
void ownerAware_maskWhenNotOwner() {
|
||||
assertEquals("가*다", StringMaskingUtil.maskName("가나다", "user1", "user2"));
|
||||
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user2"));
|
||||
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user2"));
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -54,7 +54,7 @@ class InquiryCommentPermissionCheckerTest {
|
||||
|
||||
@Test
|
||||
void canWrite_inProgressTrue() {
|
||||
assertTrue(checker.canWrite(inquiry("RESPONDED")));
|
||||
assertTrue(checker.canWrite(inquiry("IN_PROGRESS")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,14 +64,14 @@ class InquiryCommentPermissionCheckerTest {
|
||||
|
||||
@Test
|
||||
void canDelete_ownerInOpenTrue() {
|
||||
Inquiry i = inquiry("RESPONDED");
|
||||
Inquiry i = inquiry("IN_PROGRESS");
|
||||
InquiryComment c = comment("alice", i);
|
||||
assertTrue(checker.canDelete(c, user("alice")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void canDelete_otherUserFalse() {
|
||||
Inquiry i = inquiry("RESPONDED");
|
||||
Inquiry i = inquiry("IN_PROGRESS");
|
||||
InquiryComment c = comment("alice", i);
|
||||
assertFalse(checker.canDelete(c, user("bob")));
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class InquiryCommentPermissionCheckerTest {
|
||||
|
||||
@Test
|
||||
void assertDeletable_notOwnerThrowsNotOwned() {
|
||||
Inquiry i = inquiry("RESPONDED");
|
||||
Inquiry i = inquiry("IN_PROGRESS");
|
||||
InquiryComment c = comment("alice", i);
|
||||
assertThrows(InquiryCommentNotOwnedException.class,
|
||||
() -> checker.assertDeletable(c, user("bob")));
|
||||
@@ -110,7 +110,7 @@ class InquiryCommentPermissionCheckerTest {
|
||||
|
||||
@Test
|
||||
void assertDeletable_ownerInOpenOk() {
|
||||
Inquiry i = inquiry("RESPONDED");
|
||||
Inquiry i = inquiry("IN_PROGRESS");
|
||||
InquiryComment c = comment("alice", i);
|
||||
assertDoesNotThrow(() -> checker.assertDeletable(c, user("alice")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user