관리자 로그인 기본 기능 구현

This commit is contained in:
geonhos 2024-05-08 09:09:55 +09:00
parent 3e45b23da5
commit 427e072c88
59 changed files with 2741 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/.idea/

View File

@ -0,0 +1,28 @@
# Proof of Concept
## [관리자](poc%2Fadmin)
### 관리자의 기본 기능을 구현한 PoC
#### 1. Function
- 로그인
- 인증/인가
#### 2. Stack
- Spring Security
- Json Web Token (JWT)
- Java Persistence Api (JPA)
- MariaDB
- Docker (for Database, 선택사항)
#### 3. Description
1. Docker DB 실행 (또는 Local DB 사용)
- DB 환경 설정: [server.cnf](poc%2Fadmin%2Fdatabase%2Fconf.d%2Fserver.cnf)
- JPA 설정: [application-local.yml](poc%2Fadmin%2Fsrc%2Fmain%2Fresources%2Fapplication-local.yml)
- 계정 설정: [docker-compose.yml](poc%2Fadmin%2Fdatabase%2Fdocker-compose.yml)
- 초기 데이터: [data.sql](poc%2Fadmin%2Fsrc%2Fmain%2Fresources%2Fdata.sql)
```shell
cd [경로]\bp\poc\admin\database
# DB 실행
docker compose up -d # detach mode
# DB 종료
docker compose -v down # 종료 (-v 옵션 줄 경우 volume 삭제)
```
2. 프로젝트 실행

37
poc/admin/.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

55
poc/admin/build.gradle Normal file
View File

@ -0,0 +1,55 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.bpgroup.poc'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// dev container
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// db
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@ -0,0 +1,19 @@
[mysqld]
# utf8mb4: 이모지 사용 가능
init_connect=SET collation_connection = utf8mb4_general_ci
init_connect=SET NAMES utf8mb4
collation-server=utf8mb4_general_ci
character-set-server=utf8mb4
# table 소문자
lower_case_table_names=1
# transaction 독립성 level
transaction-isolation=READ-COMMITTED
# 변경 행 Base64 Encoding 후 binary log 에 저장
binlog-format=ROW
# 1: 테이블 단위로 테이블 스페이스 생성
innodb-file-per-table=1
skip-innodb-read-only-compressed

View File

@ -0,0 +1,20 @@
version: '3.8'
services:
mariadb:
container_name: mariadb
image: mariadb:10.11.7
restart: always
ports:
- 3307:3306
volumes:
- data:/var/lib/mysql # named volume
- ./conf.d:/etc/mysql/conf.d # volume mount
environment:
- TZ=Asia/Seoul
- MARIADB_ROOT_PASSWORD=root
- MARIADB_DATABASE=login
- MARIADB_USER=admin
- MARIADB_PASSWORD=1234
volumes:
data:

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
poc/admin/gradlew vendored Normal file
View File

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
poc/admin/gradlew.bat vendored Normal file
View File

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1 @@
rootProject.name = 'admin'

View File

@ -0,0 +1,15 @@
package com.bpgroup.poc.admin;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@SpringBootApplication
public class AdminApplication {
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
}
}

View File

@ -0,0 +1,22 @@
package com.bpgroup.poc.admin.app.login;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public class LoginResult {
private Long id;
private String loginId;
private String name;
private String email;
public static LoginResult of(Long id, String loginId, String name, String email) {
LoginResult loginResult = new LoginResult();
loginResult.id = id;
loginResult.loginId = loginId;
loginResult.name = name;
loginResult.email = email;
return loginResult;
}
}

View File

@ -0,0 +1,39 @@
package com.bpgroup.poc.admin.app.login;
import com.bpgroup.poc.admin.app.login.exception.AdministratorNotFoundException;
import com.bpgroup.poc.admin.app.login.exception.InvalidPasswordException;
import com.bpgroup.poc.admin.domain.admin.Administrator;
import com.bpgroup.poc.admin.domain.admin.AdministratorRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class LoginService {
private final AdministratorRepository loginRepository;
private final PasswordEncoder passwordEncoder;
public LoginResult login(String loginId, String pwd) throws AdministratorNotFoundException, InvalidPasswordException {
Optional<Administrator> administrator = loginRepository.findByLoginId(loginId);
if (administrator.isEmpty()) {
throw new AdministratorNotFoundException(loginId);
}
if (!passwordEncoder.matches(pwd, administrator.get().getPassword())) {
throw new InvalidPasswordException(loginId);
}
return LoginResult.of(
administrator.get().getId(),
administrator.get().getLoginId(),
administrator.get().getName(),
administrator.get().getLoginId()
);
}
}

View File

@ -0,0 +1,7 @@
package com.bpgroup.poc.admin.app.login.exception;
public class AdministratorNotFoundException extends Exception {
public AdministratorNotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,7 @@
package com.bpgroup.poc.admin.app.login.exception;
public class InvalidPasswordException extends Exception {
public InvalidPasswordException(String message) {
super(message);
}
}

View File

@ -0,0 +1,29 @@
package com.bpgroup.poc.admin.common;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.slf4j.helpers.MessageFormatter;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class FormatHelper {
/**
* Slf4J 스타일로 문자열 포멧팅을 해주는 함수
* <p>
* Example:
* <pre>
* String str1 = FormatHelper.format("{} {} {} {}", "1", 2, 3L, true);
* str1.equals("1 2 3 true");
*
* Object[] params = {"1", 2, 3L, true};
* String str2 = FormatHelper.format("{} {} {} {}", params);
* str2.equals("1 2 3 true");
* </pre>
*
* @param pattern 파싱되서 params를 적용해 포맷팅 패턴
* @param params pattern의 {} 치환
* @return 포맷팅 문자열
*/
public static String format(String pattern, Object... params) {
return MessageFormatter.arrayFormat(pattern, params).getMessage();
}
}

View File

@ -0,0 +1,22 @@
package com.bpgroup.poc.admin.domain;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@Column(name = "create_date")
@CreatedDate
protected LocalDateTime createdDate;
@Column(name = "update_date")
@LastModifiedDate
protected LocalDateTime updatedDate;
}

View File

@ -0,0 +1,28 @@
package com.bpgroup.poc.admin.domain.admin;
import com.bpgroup.poc.admin.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
@Getter
@Entity
@Table(name = "administrator")
public class Administrator extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "login_id")
private String loginId;
@Column(name = "password")
private String password;
@Column(name = "email")
private String email;
@Column(name = "name")
private String name;
}

View File

@ -0,0 +1,9 @@
package com.bpgroup.poc.admin.domain.admin;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AdministratorRepository extends JpaRepository<Administrator, Long> {
Optional<Administrator> findByLoginId(String loginId);
}

View File

@ -0,0 +1,22 @@
package com.bpgroup.poc.admin.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
}
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,74 @@
package com.bpgroup.poc.admin.security;
import com.bpgroup.poc.admin.common.FormatHelper;
import com.bpgroup.poc.admin.security.authentication.AuthenticationFailException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import java.util.Objects;
@Configuration
public class SecurityConfig {
private static final String LOGIN_PATH = "/login";
private static final String LOGOUT_PATH = "/logout";
private static final String ERROR_PATH = "/error";
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
csrfTokenRequestAttributeHandler.setCsrfRequestAttributeName("_csrf");
http.csrf(t -> {
t.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
.ignoringRequestMatchers("/common/modal/**")
.ignoringRequestMatchers(LOGIN_PATH, LOGOUT_PATH, ERROR_PATH) // CSRF 무시 URL 설정
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); // CSRF 토큰을 쿠키에 저장 사용 가능
}).addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); // 로그인이 완료된 CSRF Filter 실행
// 인증 설정
http.authorizeHttpRequests(c -> c
.requestMatchers("/css/**", "/images/**", "/js/**").permitAll()
.requestMatchers("/common/modal/**").permitAll()
.requestMatchers(LOGIN_PATH, LOGOUT_PATH, ERROR_PATH).permitAll()
.anyRequest().authenticated());
http.formLogin(c -> c
.loginPage(LOGIN_PATH)
.loginProcessingUrl(LOGIN_PATH)
.usernameParameter("loginId")
.passwordParameter("password")
.defaultSuccessUrl("/main")
.failureHandler((req, res, ex) -> {
if (Objects.requireNonNull(ex) instanceof AuthenticationFailException authenticationFailException) {
res.sendRedirect(FormatHelper.format(LOGIN_PATH + "?error={}", authenticationFailException.getReason()));
} else {
res.sendRedirect(ERROR_PATH);
}
}));
http.logout(c -> c
.logoutRequestMatcher(new AntPathRequestMatcher(LOGOUT_PATH, "GET"))
.logoutSuccessUrl(LOGIN_PATH)
);
return http.build();
}
/**
* Bcrypt Version, Bcrypt Strength, Salt String 설정은 생성자를 이용하여 설정 가능
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -0,0 +1,22 @@
package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.LoginResult;
import lombok.Getter;
@Getter
public class AuthenticationDetail {
private Long id;
private String loginId;
private String name;
private String email;
public static AuthenticationDetail from(LoginResult result) {
AuthenticationDetail authenticationDetail = new AuthenticationDetail();
authenticationDetail.id = result.getId();
authenticationDetail.loginId = result.getLoginId();
authenticationDetail.name = result.getName();
authenticationDetail.email = result.getEmail();
return authenticationDetail;
}
}

View File

@ -0,0 +1,15 @@
package com.bpgroup.poc.admin.security.authentication;
import lombok.Getter;
import org.springframework.security.core.AuthenticationException;
@Getter
public class AuthenticationFailException extends AuthenticationException {
private final AuthenticationFailReason reason;
public AuthenticationFailException(String message, AuthenticationFailReason reason) {
super(message);
this.reason = reason;
}
}

View File

@ -0,0 +1,20 @@
package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.exception.AdministratorNotFoundException;
import com.bpgroup.poc.admin.app.login.exception.InvalidPasswordException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum AuthenticationFailReason {
WRONG_LOGIN_ID,
WRONG_PASSWORD,
INTERNAL_ERROR;
public static AuthenticationFailReason from(Exception e) {
if (e instanceof AdministratorNotFoundException || e instanceof InvalidPasswordException) {
return WRONG_LOGIN_ID;
} else {
return INTERNAL_ERROR;
}
}
}

View File

@ -0,0 +1,45 @@
package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.LoginResult;
import com.bpgroup.poc.admin.app.login.LoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final LoginService loginService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
try {
LoginResult loginResult = loginService.login(loginId, password);
return buildAuthenticationToken(loginResult);
} catch (Exception e) {
throw new AuthenticationFailException("로그인에 실패하였습니다.", AuthenticationFailReason.from(e));
}
}
private UsernamePasswordAuthenticationToken buildAuthenticationToken(LoginResult result) {
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(
result.getLoginId(),
null,
null
);
authenticationToken.setDetails(AuthenticationDetail.from(result));
return authenticationToken;
}
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}

View File

@ -0,0 +1,41 @@
package com.bpgroup.poc.admin.web.fragment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Slf4j
@Controller
@RequestMapping("/common/modal")
@RequiredArgsConstructor
public class ModalController {
@RequestMapping(method = {RequestMethod.GET, RequestMethod.POST}, value = "alert")
public String alert(
@ModelAttribute("title") String title,
@ModelAttribute("message") String message,
Model model
) {
model.addAttribute("title", title);
model.addAttribute("message", message);
return "fragment/modal/alert-modal";
}
@RequestMapping(method = {RequestMethod.GET, RequestMethod.POST}, value = "confirm")
public String confirm(
@ModelAttribute("title") String title,
@ModelAttribute("message") String message,
Model model
) {
model.addAttribute("title", title);
model.addAttribute("message", message);
return "fragment/modal/confirm-modal";
}
}

View File

@ -0,0 +1,31 @@
package com.bpgroup.poc.admin.web.login;
import com.bpgroup.poc.admin.security.authentication.AuthenticationFailReason;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class LoginController {
@GetMapping("/login")
public String loginPage(
@RequestParam(required = false) AuthenticationFailReason error,
Model model
) {
if (error != null) {
model.addAttribute("errorMessage", getMessage(error));
}
return "login/login";
}
private String getMessage(AuthenticationFailReason error) {
return switch (error) {
case WRONG_LOGIN_ID, WRONG_PASSWORD -> "아이디 또는 비밀번호가 일치하지 않습니다.";
case INTERNAL_ERROR -> "서버에 오류가 발생했습니다.";
};
}
}

View File

@ -0,0 +1,14 @@
package com.bpgroup.poc.admin.web.main;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MainController {
@GetMapping("/main")
public String mainPage() {
return "main/main";
}
}

View File

@ -0,0 +1,19 @@
spring:
sql:
init:
mode: always
datasource:
url: jdbc:mariadb://localhost:3307/login
username: admin
password: 1234
driver-class-name: org.mariadb.jdbc.Driver
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: create-drop
defer-datasource-initialization: true

View File

@ -0,0 +1,6 @@
spring:
application:
name: login
profiles:
default: local

View File

@ -0,0 +1,2 @@
INSERT INTO `administrator` (`login_id`, `password`, `email`, `name`, `create_date`, `update_date`)
VALUES ('admin', '$2a$10$g6UOrQ/OS8o5r5CJk7C5juVFaItQ62U3EIn8zLPzkFplM3wVLvKZ2', 'admin@admin.com', '홍길동', CURDATE(), CURDATE());

View File

@ -0,0 +1,56 @@
/* style reset */
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre, a,
abbr, address, big, cite, code, del,
dfn, em, img, ins, kbd, q, s, samp, small,
strike, strong, sub, sup, tt, var, b, u, i,
center, dl, dt, dd, ol, ul, li, fieldset, form,
label, legend, table, caption, tbody, tfoot,
thead, tr, th, td, article, aside, canvas,
details, embed, figure, figcaption, footer,
header, hgroup, menu, nav, output, ruby, section,
summary, time, mark, audio, video, button, a, input {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
-webkit-tap-highlight-color : rgba(0,0,0,0);
line-height: 150%;
box-sizing: border-box;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block
}
body {
line-height: 1
}
ol, ul {
list-style: none
}
blockquote, q {
quotes: none
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none
}
table {
border-collapse: collapse;
border-spacing: 0
}
a {
text-decoration: none
}
* {
font-family: 'Pretendard', 'sans-serif';
box-sizing: border-box;
}

View File

@ -0,0 +1,802 @@
@charset "utf-8";
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
@import 'reset.css';
:root {
/* color */
--color-black: #000000;
--color-white: #ffffff;
/* font */
--fs-30 : 1.875rem;
--fs-29 : 1.813rem;
--fs-28 : 1.75rem;
--fs-27 : 1.688rem;
--fs-26 : 1.625rem;
--fs-25 : 1.563rem;
--fs-24 : 1.5rem;
--fs-23 : 1.438rem;
--fs-22 : 1.375rem;
--fs-21 : 1.3125rem;
--fs-20 : 1.25rem;
--fs-19 : 1.1875rem;
--fs-18 : 1.125rem;
--fs-17 : 1.0625rem;
--fs-16 : 1rem;
--fs-15 : 0.9375rem;
--fs-14 : 0.875rem;
--fs-13 : 0.8125rem;
--fs-12 : 0.75rem;
--fs-11 : 0.6875rem;
--fs-10 : 0.625rem;
--fw-800 : 800;
--fw-700 : 700;
--fw-600 : 600;
--fw-500 : 500;
--fw-400 : 400;
--fw-300 : 300;
--fw-200 : 200;
--fw-100 : 100;
}
/* 공통 */
.blind {
visibility: hidden;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
font-size: 0;
line-height: 0
}
.wid400 {
width: 400px;
}
/* button */
button {
cursor: pointer;
border-radius: 3px;
}
a:active, button:active {
opacity: .75;
}
button:disabled {
color: #999;
opacity: 0.5;
}
.btn_normal {
font-size: var(--fs-14);
color: #333;
height: 36px;
padding: 0 10px;
border: 1px solid #999;
background-color: #fff;
}
.btn_confirm {
font-size: var(--fs-14);
color: #fff;
height: 36px;
padding: 0 10px;
border: 1px solid #a6a6a6;
background-color: var(--color-black);
}
.closeButton {
display: none;
position: absolute;
top: 15px;
right: 15px;
background: transparent;
}
/* layout */
html, body {
height: 100vh;
min-height: -webkit-fill-available;
min-height: var(--vh-100);
font-size: 16px;
color: #333;
line-height: 150%;
font-family: 'Pretendard', 'sans-serif';
}
header {
/*position: fixed;
left: 0;
top: 0;*/
width: 100%;
padding: 16px 20px;
border-bottom: 1px solid #c6c6c6;
overflow: hidden;
background-color: #ffffff;
z-index: 100;
}
header a.nav_menu {
display: none;
}
.wrapper {
margin: 0 auto;
height: 100vh;
}
.container {
padding: 10px 30px 30px 30px;
margin: 0 auto;
min-width: 320px;
overflow-y: auto;
}
.container h2 {
font-size: var(--fs-30);
font-weight: var(--fw-800);
margin: 15px 0 10px 0;
}
.breadcrumb a {
font-size: 15px;
color: #999;
}
.breadcrumb a::after {
position: relative;
content: ">";
padding-left: 10px;
padding-right: 5px;
font-size: 10px;
top: -2px
}
.breadcrumb a:last-child {
color: #333;
}
.breadcrumb a:last-child::after {
content: " ";
}
.option_wrap {
position: relative;
padding: 15px 12px;
border: 1px solid #d6d6d6;
border-radius: 6px;
}
.option_wrap ul {
overflow: hidden;
}
.option_wrap ul li {
display: inline-block;
padding: 5px 10px;
}
.option_wrap ul li span {
display: inline-block;
padding: 3px 0;
}
.position_right {
position: absolute;
right: 12px;
bottom: 12px;
}
/* header */
header h1 {
float: left;
font-size: var(--fs-26);
font-weight: var(--fw-800)
}
.menu_right {
float: right;
font-size: var(--fs-14);
font-weight: var(--fw-400);
color: #666;
margin-top: 10px;
}
.menu_right a {
display: inline-block;
padding: 0 10px;
}
/* input */
[tabindex='-1'], [tabindex='0'] {
outline: none;
}
input {
-webkit-appearance : none;
-moz-appearance:none;
appearance:none;
height: 36px;
border: 1px solid #c6c6c6;
padding: 0 10px;
box-sizing: border-box;
outline: none;
font-size: var(--fs-15);
color: #333;
border-radius: 3px;
}
input:focus, input:active, input:hover {
border: 1px solid #333;
outline: none;
}
input[type=number] {
-moz-appearance: textfield;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
}
input::-ms-clear {
display: none;
}
input:invalid {
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
:-moz-submit-invalid {
-moz-box-shadow: none;
box-shadow: none;
}
:-moz-ui-invalid {
-moz-box-shadow: none;
box-shadow: none;
}
input:focus-within {
border: 1px solid #333;
}
input:disabled {
background: #f5f5f5;
}
input:read-only {
color: var(--color-black);
background: #f5f5f5;
}
input::placeholder {
color: #c6c6c6;
font-weight: var(--fw-400);
}
*::placeholder {
font-weight: var(--fw-300);
}
.date_select input {
font-size: var(--fs-15);
}
/* checkbox */
.check_box {
position: relative;
display: inline-block;
font-size: var(--fs-15);
}
.check_box label {
cursor: pointer;
}
input[type="checkbox"] + label {
position: relative;
top: 5px;
display: block;
width: 16px;
height: 16px;
background: url(../images/chk_off.svg) no-repeat 0 0 / contain;
}
input[type='checkbox']:checked + label {
background: url(../images/chk_on.svg) no-repeat 0 0 / contain;
}
input[type="checkbox"] {
display: none;
}
/* radiobox */
.radio_box {
position: relative;
padding-left: 33px;
padding-right: 30px;
font-size: var(--fs-16);
margin-left: 12px;
}
.radio_box label {
position: absolute;
left: 3px;
top: 2px;
cursor: pointer;
}
input[type="radio"] + label {
position: absolute;
display: block;
height: 20px;
padding-left: 26px;
background: url(../images/radio_off.svg) no-repeat 0 0 / contain;
}
input[type='radio']:checked + label {
background: url(../images/radio_on.svg) no-repeat 0 0 / contain;
}
input[type="radio"] {
display: none;
}
/* select */
select {
-webkit-appearance: none;
-moz-appearance:none;
appearance:none;
height: 36px;
padding: 0 25px 0 10px;
border: 0;
color: #333;
font-size: var(--fs-14);
font-weight: var(--fw-400);
border-radius: 3px;
-moz-border-radius: 3px;
outline: 0;
background: url('../images/ico_down_black.svg') no-repeat center right 8px, var(--color-white);
background-size: 10px auto;
transition: all .2s;
border: 1px solid #c6c6c6;
font-family: 'Pretendard', 'sans-serif';
}
select:disabled {
opacity: 0.6;
}
select::-ms-expand {
display:none;
}
select:focus-within, select:active {
border: 1px solid #333;
}
select:disabled {
background-color: #f9f9f9;
}
select:disabled + span {
opacity: 0.3;
}
/* flex layout */
.flexbox {
position: relative;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-moz-box-align: center;
-ms-flex-align: center;
flex-direction: row;
align-items: stretch
}
.flexitem {
flex: 1;
-webkit-box-flex: 1;
-moz-box-flex: 1;
-ms-flex: 1;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-moz-box-orient: horizontal;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
/* navigation */
.menu_wrap {
width: 100%;
max-width: 250px;
min-width: 200px;
height: 100%;
overflow: auto;
background-color: #333;
padding: 20px 0;
}
.menu_wrap h2 {
display: block;
font-size: var(--fs-20);
font-weight: var(--fw-800);
color: #fff;
padding: 0 20px;
margin-bottom: 10px;
}
.menu_wrap_mobile {
display: none;
}
.menu_wrap_mobile h2 {
display: block;
font-size: var(--fs-20);
font-weight: var(--fw-800);
color: #fff;
padding: 0 20px;
margin-bottom: 10px;
}
.nav_tit {
position: relative;
display: block;
cursor: pointer;
font-size: var(--fs-15);
color: #fff;
background: url('../images/ico_down.svg') no-repeat right 20px center;
background-size: 10px;
transition: background 0.1s ease;
padding: 8px 20px;
}
.nav_tit.active {
background: url('../images/ico_up.svg') no-repeat right 20px center, #222;
background-size: 10px;
}
.nav_tit.active + .nav_section {
display: block;
}
.nav_section {
display: none;
list-style: none;
padding: 10px 20px;
}
.nav_section li {
padding: 2px 10px;
}
.nav_section li a {
display: block;
color: #fff;
font-size: var(--fs-14);
}
/* table */
.table_wrap {
position: relative;
}
.table_wrap.mt-20 {
margin-top: 20px;
}
.table_wrap h4 {
display: inline-block;
font-size: 22px;
font-weight: var(--fw-800);
height: 36px;
line-height: 36px;
margin-bottom: 10px;
}
.tb_right {
position: absolute;
right: 0;
top: 0;
}
table {
width: 100%;
border-top: 1px solid #333; table-layout:fixed;
}
table caption{font-size:0; text-indent:-9999px;}
th, td {
text-align: center;
padding: 12px 8px;
color: #333;
}
th {
font-size: var(--fs-14);
font-weight: var(--fw-700);
background-color: #f9f9f9;
border-bottom: 1px solid #c6c6c6
}
td {
font-size: var(--fs-15);
font-weight: var(--fw-400);
border-bottom: 1px solid #e6e6e6;
}
/* modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
display: inline-block;
width: 100%;
max-width: 320px;
transform: translate(-50%, -50%);
background-color: white;
padding: 16px;
}
.modal-header {
position: absolute;
top: 0;
left: 0;
width: 100%;
border-bottom: 1px solid #d6d6d6;
}
.modal-header h2 {
height: 50px;
line-height: 50px;
font-size: 20px;
margin: 0;
padding: 0 15px;
}
.close {
position: absolute;
top: 10px;
right: 10px;
font-size: 20px;
cursor: pointer;
}
.modal-body {
margin-top: 50px;
font-size: var(--fs-15);
padding: 10px 0;
}
.modal-body .col-box {
padding-bottom: 16px;
}
p.col-box label {
display: block;
margin-bottom: 5px;
}
p.col-box input {
width: 100%;
}
.btn_both {
display: flex;
}
.btn_both button {
width: 50%;
flex: 1;
}
.btn_both button:nth-child(1) {
margin-right: 3px;
}
.btn_both button:nth-child(2) {
margin-left: 3px;
}
/* login */
.login_wrap {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1;
}
.login_box {
position: absolute;
top: 50%;
left: 50%;
display: inline-block;
width: 100%;
max-width: 420px;
transform: translate(-50%, -50%);
background-color: white;
padding: 30px;
border-radius: 6px;
}
.login_box h1 {
font-size: var(--fs-26);
font-weight: var(--fw-800);
margin-top: 5px;
margin-bottom: 20px;
}
.login_box p {
padding: 6px 0;
}
input.login_in {
width: 40% !important;
margin-right: 5px;
}
.login_box label {
display: inline-block;
min-width: 75px;
text-align: right;
padding-right: 10px;
}
.login_box input {
width: 76%;
}
.btn_login {
width: 100% !important;
margin-top: 30px;
height: 44px;
font-size: var(--fs-15);
font-weight: var(--fw-800);
}
.login_subtit {
position: relative;
top: -20px;
color: #999;
}
/* main */
.graph {
margin-top: 15px;
padding: 25px;
background-color: #f9f9f9;
}
.graph canvas {
max-height: 200px;
}
.main_count {
padding: 30px 0;
}
.count_wrap {
overflow: hidden;
width: 100%;
display: flex;
}
.count_wrap > li {
flex: 1;
font-size: var(--fs-16);
padding: 25px;
border: 1px solid #d6d6d6;
border-radius: 6px;
margin-right: 30px;
}
.count_wrap > li:last-child {
margin-right: 0;
}
.count_wrap > li strong {
font-weight: var(--fw-700);
font-size: var(--fs-15);
}
.count_wrap > li p {
position: relative;
padding-left: 10px;
margin-bottom: 5px;
}
.count_wrap > li p em {
position: absolute;
left: 0;
top: 0;
}
.count_wrap h5 {
font-size: var(--fs-18);
font-weight: var(--fw-800);
margin-bottom: 15px;
}
.date {
position: relative;
top: -1px;
left: 15px;
display: inline-block;
font-size: var(--fs-15);
}
/* 모바일 */
@media (max-width: 767px){
header {
padding: 10px 16px;
}
header h1 {
font-size: 20px;
}
header a.nav_menu {
position: absolute;
display: block;
top: 15px;
right: 15px;
}
.container {
padding: 16px;
height: 100%;
overflow: inherit;
}
.container h2 {
margin: 0 0 10px 0;
}
.menu_wrap, .menu_right, .breadcrumb {
display: none;
}
.menu_wrap.active {
position: fixed !important;
display: block;
top: 0 !important;
left: 0 !important;
width: 100%;
height: 100%;
max-width: inherit;
background-color: #333;
z-index: 1000;
}
.tb_right {
position: relative;
margin-bottom: 10px;
}
.closeButton {
display: block;
}
.option_wrap ul li {
width: 100%;
}
.position_right {
position: inherit;
}
.wid400 {
width: 100%;
}
.tb_wrapper {
overflow-x: auto;
}
.tb_sty01 {
width: 1000px;
}
th, td {
font-size: var(--fs-15);
}
.count_wrap {
display: block;
}
.count_wrap li {
flex: none;
padding: 16px;
margin-right: 0;
margin-bottom: 16px;
}
.graph {
padding: 16px;
}
.login_box label {
display: block;
text-align: left;
margin-bottom: 5px;
}
.login_box {
width: 92%;
}
.login_box input {
width: 100%;
}
.login_box input.login_in {
width: 60%;
}
/* modal */
.modal-content {
width: 92%;
max-width: inherit;
}
}
/* 테블릿 세로 */
@media (min-width: 768px) and (max-width: 991px) {
}
/* 테블릿 가로 */
@media (min-width: 992px) and (max-width: 1199px) {
}
/* 데스크탑 일반 */
@media (min-width: 1200px) {
}

View File

@ -0,0 +1,136 @@
/*20240220 김보라 퍼블수정*/
html, body {height:calc(100vh - 72px);}
.wrapper {height:100%}
#dayKo {margin-left: 20px;}
.terminal_wrap {margin:20px 0 10px 0; padding:15px; background:#f5f5f5}
.terminal_wrap .terminal_top{overflow: hidden;}
.terminal_wrap .terminal_top .terminal_btn {float:right;}
.terminal_wrap .terminal_list_wrap {border-top:1px solid #ddd; margin: 10px 0 0 0; padding:10px}
.terminal_list_wrap .terminal_list_in {padding: 5px 0;}
.terminal_list_wrap .terminal_list_in .checkbox-label {line-height: normal; padding-left: 25px; display: inline;}
.terminal_list_wrap .terminal_list_in > p {text-align: center;}
.table_radio_wrap {vertical-align: middle;}
.table_radio_wrap .radio_box {display: inline-block !important; padding-right:10px !important; padding-left: 0; margin-left:0; }
.table_radio_wrap .radio_box input[type="radio"] + label {position:static;}
.btn_wrap {position: relative; text-align: right; margin-top:10px;}
.table_select_box, .table_date_box {width: 225px;}
.tb_sty01.tb_sty02 {border-top:1px solid #999}
#batchRegisterTerminalModal .modal-content {max-width: 530px !important}
#depositDetailModal .modal-content, #withdrawDetailModal .modal-content,
#transferDetailModal .modal-content, #transferHistoryModal .modal-content {max-width: 750px !important}
.btn_normal.excelbtn {
height: 36px;
display: inline-block;
vertical-align: top;
line-height: 36px;
}
.tb_wrapper.tb_wrapper_x_scroll {overflow-x: auto;}
.tb_wrapper.tb_wrapper_terminal {max-height: 170px; overflow-y: auto; overflow-x: auto;}
.wd_auto .tbl_bert_m > th{vertical-align: middle;}
.wd_auto input {width:100% !important;}
.font_bold{font-weight: bold;}
.tooltip_prt {position:relative;}
.tooltip_chd {
visibility: hidden;
background-color: black;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 1;
width: max-content;
bottom: -5%;
left: 50%;
transform: translate(-50%, 50%);
}
.tooltip_prt:hover .tooltip_chd {
visibility: visible;
}
table colgroup > col { min-width:max-content !important}
table td > p, table td > a {
display:block;
overflow:hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mt-05 {
margin-top: 5px;
}
.mt-10 {
margin-top: 10px;
}
.tb_wrapper {padding-bottom:35px}
/*20240226 김현종 퍼블 수정*/
.clickable_text a:link, .clickable_text a:visited {
color: blue;
text-decoration: underline;
cursor: pointer;
}
/*20240229 김현종 퍼블 수정*/
.title_text a {
color: black; /* 원하는 색상으로 설정 */
text-decoration: none; /* 밑줄 제거 */
}
.title_text a:visited {
color: black; /* 방문한 링크에 대해서도 동일한 색상 유지 */
}
/* 모바일 */
@media (max-width: 767px){
.mb_w50 {overflow: hidden; margin-left:-5px;}
.mb_w50 > span {width:50%; float:left; padding:0 3px !important; box-sizing: border-box;}
.mb_w50 > span select {width:100%}
table td > p, table td > a {
overflow: inherit;
white-space: inherit;
word-break: break-all;
}
.tb_w_auto {width:130px !important;}
.table_wrap h4 { height: auto !important}
.table_wrap2 .tb_right {position:absolute;}
}
/* 테블릿 세로 */
@media (min-width: 768px) and (max-width: 991px) {
.menu_wrap {
max-width:200px
}
.tb_w_auto {width:100px}
}
/* 테블릿 가로 */
@media (min-width: 992px) and (max-width: 1199px) {
.menu_wrap {
max-width:200px
}
.tb_w_auto {width:130px}
}
/* 데스크탑 일반 */
@media (min-width: 1200px) {
}

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g id="chk_off" fill="#fff" stroke="#007fa8" stroke-width="1">
<rect width="20" height="20" stroke="none"/>
<rect x="0.5" y="0.5" width="19" height="19" fill="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g id="chk_on" transform="translate(-1599 -1046)">
<rect id="사각형_1369" data-name="사각형 1369" width="20" height="20" transform="translate(1599 1046)" fill="#007fa8"/>
<path id="패스_412" data-name="패스 412" d="M2915.522-3564.191l-5.052-5.151,1.53-1.5,3.361,3.427,6.459-8.055,1.672,1.34Z" transform="translate(-1307.941 4625.084)" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15.603" height="15.603" viewBox="0 0 15.603 15.603">
<g id="ico_close" transform="translate(-324.162 -20.162)">
<line id="선_19" data-name="선 19" x2="14.189" y2="14.189" transform="translate(324.869 20.869)" fill="none" stroke="#fff" stroke-width="2"/>
<line id="선_20" data-name="선 20" x1="14.189" y2="14.189" transform="translate(324.869 20.869)" fill="none" stroke="#fff" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="8" viewBox="0 0 13 8">
<path id="패스_1291" data-name="패스 1291" d="M1321.314,229.728l-5.2-5.333-1.3,1.334,6.5,6.666h0l6.5-6.666-1.3-1.334Z" transform="translate(-1314.815 -224.395)" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="8" viewBox="0 0 13 8">
<path id="패스_1291" data-name="패스 1291" d="M1321.314,229.728l-5.2-5.333-1.3,1.334,6.5,6.666h0l6.5-6.666-1.3-1.334Z" transform="translate(-1314.815 -224.395)" fill="#333"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="12" viewBox="0 0 20 12">
<g id="그룹_2754" data-name="그룹 2754" transform="translate(-320 -21)">
<rect id="사각형_1995" data-name="사각형 1995" width="20" height="2" transform="translate(320 21)" fill="#333"/>
<rect id="사각형_1996" data-name="사각형 1996" width="20" height="2" transform="translate(320 31)" fill="#333"/>
<rect id="사각형_1997" data-name="사각형 1997" width="20" height="2" transform="translate(320 26)" fill="#333"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 546 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="8" viewBox="0 0 13 8">
<path id="패스_330" data-name="패스 330" d="M1321.314,227.062l-5.2,5.333-1.3-1.334,6.5-6.666h0l6.5,6.666-1.3,1.334Z" transform="translate(-1314.815 -224.395)" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<g id="radio_off" transform="translate(0 -1)">
<rect id="사각형_11" data-name="사각형 11" width="18" height="18" rx="9" transform="translate(0 1)" fill="#fff"/>
<path id="사각형_11_-_윤곽선" data-name="사각형 11 - 윤곽선" d="M9,2a7,7,0,1,0,7,7A7.008,7.008,0,0,0,9,2M9,0A9,9,0,1,1,0,9,9,9,0,0,1,9,0Z" transform="translate(0 1)" fill="#007fa8"/>
<circle id="타원_7" data-name="타원 7" cx="4" cy="4" r="4" transform="translate(5 6)" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 578 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<g id="radio_on" transform="translate(0 -1)">
<rect id="사각형_11" data-name="사각형 11" width="18" height="18" rx="9" transform="translate(0 1)" fill="#fff"/>
<path id="사각형_11_-_윤곽선" data-name="사각형 11 - 윤곽선" d="M9,2a7,7,0,1,0,7,7A7.008,7.008,0,0,0,9,2M9,0A9,9,0,1,1,0,9,9,9,0,0,1,9,0Z" transform="translate(0 1)" fill="#007fa8"/>
<circle id="타원_7" data-name="타원 7" cx="4" cy="4" r="4" transform="translate(5 6)" fill="#007fa8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@ -0,0 +1,37 @@
const EventRouter = {
_eventRouter: $({}),
/**
* 이벤트 발생
*
* @param {string} eventName
* @param {*=} data
*/
trigger(eventName, data) {
this._eventRouter.trigger(eventName, data);
},
/**
* 이벤트 등록
*
* @param {string} eventName
* @param {function(*)} func
*/
register(eventName, func) {
if (!eventName) {
console.warn('eventName이 비어있습니다.');
return;
}
this._eventRouter.off(eventName);
this._eventRouter.on(eventName, func);
},
/**
* 현재 등록된 이벤트 조회 (디버깅용)
*/
logCurrentRegistered() {
const events = $._data(this._eventRouter[0], "events");
console.log(events);
}
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,73 @@
// mobile 100vh err issue 대응
const setVh = () => {
document.documentElement.style.setProperty('--vh', `${window.innerHeight}px`)
};
window.addEventListener('resize', setVh);
setVh();
$(document).on('touchstart', function () {
});
// 콘텐츠영역 자동 높이 조절
$(document).ready(function () {
// var headerHeight = $('header').outerHeight();
// $('.wrapper').css('padding-top', headerHeight + 'px');
});
$(document).ready(function () {
setupAccordion();
// navigation 모바일 처리
$('#toggleLink').click(function () {
toggleMenu('.menu_wrap');
});
$('.closeButton').click(function () {
closeMenu('.menu_wrap');
});
//modal
$("#openModalBtn").click(function () {
openModal();
});
$(".modal .close, .modal").click(function (e) {
if (e.target !== this) return; // 모달 내부를 클릭한 경우에는 닫히지 않도록 처리
closeModal();
});
});
// 아코디언
function setupAccordion() {
$('.nav_tit').on('click', function () {
$(this).next('.nav_section').slideToggle(150);
$(this).toggleClass('active');
$('.nav_section').not($(this).next('.nav_section')).slideUp(150);
$('.nav_tit').not($(this)).removeClass('active');
});
}
// navigation 모바일 처리
function toggleMenu(selector) {
$(selector).toggleClass('active');
}
function closeMenu(selector) {
$(selector).removeClass('active');
}
//modal 열기 닫기
function openModal() {
$(".modal").fadeIn(100);
$("body").css("overflow", "hidden");
}
function closeModal() {
$(".modal").fadeOut(100);
$("body").css("overflow", "auto");
}

View File

@ -0,0 +1,182 @@
const PageHelper = {
/**
* 단순 확인 모달을 띄운다.
*
* @param message
* @param confirmFunction
*/
showSimpleConfirmModal(message, confirmFunction) {
this.showConfirmModal({
title: '확인',
message: message,
}, {
confirm: confirmFunction,
})
},
/**
* Confirm 모달을 띄운다.
*
* @param {Object} params
* @param {string} params.title
* @param {string} params.message
* @param {Object=} callbacks
* @param {function()=} callbacks.confirm
* @param {function()=} callbacks.cancel
*/
showConfirmModal(params, callbacks) {
if (!callbacks) {
callbacks = {};
}
this.loadAndApplyPage({
url: `/common/modal/confirm`,
method: 'POST',
data: `title=${params.title}&message=${params.message}`,
contentSelector: 'div[data-confirm-modal="body"]',
appendToSelector: 'body'
}, {
success: function () {
EventRouter.register('clickConfirmModalConfirmButton', callbacks.confirm);
EventRouter.register('clickConfirmModalCancelButton', callbacks.cancel);
},
});
},
/**
* 단순 오류 모달을 띄운다.
*
* @param message
* @param cancelFunction
*/
showErrorModal(message, cancelFunction) {
if (!cancelFunction) {
cancelFunction = () => {}
}
this.showAlertModal({
title: '오류',
message: message,
}, {
cancel: cancelFunction
});
},
/**
* 단순 완료 모달을 띄운다.
*
* @param message
* @param cancelFunction
*/
showFinishModal(message, cancelFunction) {
if (!cancelFunction) {
cancelFunction = () => {}
}
this.showAlertModal({
title: '완료',
message: message,
}, {
cancel: cancelFunction
});
},
/**
* alert 모달을 띄운다.
*
* @param {Object} params
* @param {string} params.title
* @param {string} params.message
* @param callbacks=
* @param {function()=} callbacks.cancel
*/
showAlertModal(params, callbacks) {
if (!callbacks) {
callbacks = {};
}
this.loadAndApplyPage({
url: `/common/modal/alert`,
method: 'POST',
data: `title=${params.title}&message=${params.message}`,
contentSelector: 'div[data-alert-modal="body"]',
appendToSelector: 'body'
}, {
success: function () {
EventRouter.register('clickAlertModalCancelButton', callbacks.cancel);
},
});
},
/**
* @param {Object} params
* @param {string} params.url
* @param {string} params.method
* @param {string=} params.data
* @param {string} params.contentSelector
* @param {string} params.appendToSelector
* @param {Object=} callbacks
* @param {function(jQuery)=} callbacks.success - modal body의 jquery 객체가 파라미터로 오는 success 콜백 함수
* @param {function(Object)=} callbacks.error - 에러 메세지가 파라미터로 오는 error 콜백 함수
* @param {function()=} callbacks.complete
*/
loadAndApplyPage(params, callbacks) {
if (!callbacks) {
callbacks = {};
}
this.loadPage({
url: params.url,
method: params.method,
data: params.data,
}, {
success: function (res) {
const e = $(res);
const d = e.find(params.contentSelector);
d.appendTo(params.appendToSelector);
const s = e.find('script');
s.appendTo('body');
if (callbacks.success) callbacks.success(d);
},
error: function (error) {
if (callbacks.error) callbacks.error(error);
},
complete: function () {
if (callbacks.complete) callbacks.complete();
}
})
},
/**
* @param {Object} params
* @param {string} params.url
* @param {string} params.method
* @param {string=} params.data
* @param {Object=} callbacks
* @param {function(string)=} callbacks.success
* @param {function(Object)=} callbacks.error
* @param {function()=} callbacks.complete
*/
loadPage(params, callbacks) {
if (!callbacks) {
callbacks = {};
}
$.ajax({
url: params.url,
type: params.method,
data: params.data,
success: function (res) {
if (callbacks.success) callbacks.success(res);
},
error: function (error) {
console.error(error);
if (callbacks.error) callbacks.error(error);
},
complete: function () {
if (callbacks.complete) callbacks.complete();
}
});
},
};

View File

@ -0,0 +1,81 @@
const ReqHelper = {
/**
*
* @param {jQuery} form
* @param {Object=} callbacks
* @param {function(Object=)=} callbacks.success
* @param {function(Object)=} callbacks.error
* @param {function()=} callbacks.complete
*/
reqByForm(form, callbacks) {
if (!(form instanceof jQuery)) {
throw new Error('form is not jQuery instance');
}
if (!callbacks) {
callbacks = {};
}
const url = form.attr('action');
const method = form.attr('method');
const dataObj = form.serializeArray().reduce((obj, item) => {
obj[item.name] = item.value;
return obj;
}, {});
$.ajax({
url: url,
type: method,
contentType: 'application/json',
data: JSON.stringify(dataObj),
success: function (res) {
if (!callbacks.success) return;
res.isSuccess = () => res.resultCode === "0000";
res.isFail = () => !res.isSuccess();
res.getMessage = () => res.resultMsg || '';
callbacks.success(res);
},
error: function (error) {
if (callbacks.error) callbacks.error(error);
},
complete: function () {
if (callbacks.complete) callbacks.complete();
}
});
},
/**
*
* @param url
* @param sendData {Object=}
* @param {Object=} callbacks
* @param {function(Object=)=} callbacks.success
* @param {function(Object)=} callbacks.error
* @param {function()=} callbacks.complete
*/
reqByObj(url, sendData, callbacks) {
if (!url) {
throw new Error('url is empty');
}
if (!callbacks) {
callbacks = {};
}
$.ajax({
url: url,
type: "post",
contentType: 'application/json',
data: JSON.stringify(sendData),
success: function (res) {
if (callbacks.success) callbacks.success(res);
},
error: function (error) {
if (callbacks.error) callbacks.error(error);
},
complete: function () {
if (callbacks.complete) callbacks.complete();
}
});
},
};

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<th:block th:fragment="applyCsrf">
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf" th:content="${_csrf.token}"/>
<script th:inline="javascript">
// 모든 AJAX 요청에 자동으로 CSRF 토큰이 포함
const csrfToken = $('meta[name="_csrf"]').attr('content');
const csrfHeader = $('meta[name="_csrf_header"]').attr('content');
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
</script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div>
<div class="modal" data-alert-modal="body">
<div class="modal-content">
<div class="modal-header">
<span class="close" data-alert-modal="cancelButton">&times;</span>
<h2 th:text="${title}"/>
</div>
<div class="modal-body">
<p class="col-box">
<span style="white-space: pre-line" th:text="${message}"></span>
</p>
</div>
<div class="modal-footer btn_both">
<button type="button" class="btn_normal" data-alert-modal="cancelButton">닫기</button>
</div>
</div>
</div>
<script data-alert-modal="script">
(function () {
const modalBody = $('div[data-alert-modal="body"]');
modalBody.fadeIn(100);
$("body").css("overflow", "hidden");
$('[data-alert-modal="cancelButton"]').on('click', function () {
close();
EventRouter.trigger('clickAlertModalCancelButton');
});
function close() {
modalBody.fadeOut(100);
modalBody.remove();
$("body").css("overflow", "auto");
$('script[data-alert-modal="script"]').remove();
}
})()
</script>
</div>
</body>
</html>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<div>
<div class="modal" data-confirm-modal="body">
<div class="modal-content">
<div class="modal-header">
<span class="close" data-confirm-modal="cancelButton">&times;</span>
<h2 th:text="${title}"/>
</div>
<div class="modal-body">
<p class="col-box">
<span style="white-space: pre-line" th:text="${message}"></span>
</p>
</div>
<div class="modal-footer btn_both">
<button type="button" class="btn_confirm" data-confirm-modal="confirmButton">확인</button>
<button type="button" class="btn_normal" data-confirm-modal="cancelButton">취소</button>
</div>
</div>
</div>
<script data-confirm-modal="script">
(function () {
const modalBody = $('div[data-confirm-modal="body"]');
modalBody.fadeIn(100);
$("body").css("overflow", "hidden");
$('button[data-confirm-modal="confirmButton"]').on('click', function () {
let prevented = false;
EventRouter.trigger('clickConfirmModalConfirmButton', {preventDefault: () => prevented = true});
if (prevented) {
return;
}
hideAndRemove();
});
$('[data-confirm-modal="cancelButton"]').on('click', function () {
let prevented = false;
EventRouter.trigger('clickConfirmModalCancelButton', {preventDefault: () => prevented = true});
if (prevented) {
return;
}
hideAndRemove();
});
function hideAndRemove() {
modalBody.fadeOut(100);
modalBody.remove();
$("body").css("overflow", "auto");
$('script[data-confirm-modal="script"]').remove();
}
})()
</script>
</div>
</body>
</html>

View File

@ -0,0 +1,46 @@
<!-- data-page-modal="body" / 모달 본문 -->
<!-- data-page-modal="script" / 모달 스크립트 -->
<!-- data-page-modal="cancelButton" / 닫힘 버튼, 클릭하면 모달 및 스크립트를 삭제한다. -->
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
<div>
<div class="modal" data-page-modal="body">
<div class="modal-content">
<div class="modal-header">
<span class="close" data-page-modal="cancelButton">&times;</span>
<h2>
<th:block layout:fragment="title"></th:block>
</h2>
</div>
<div class="modal-body">
<th:block layout:fragment="body"></th:block>
</div>
</div>
</div>
<script data-page-modal="script">
$('div[data-page-modal="body"]').fadeIn(100);
$("body").css("overflow", "hidden");
$('[data-page-modal="cancelButton"]').on('click', cancelModal);
function cancelModal() {
closeModal();
EventRouter.trigger('cancelModal');
}
function closeModal() {
const modalBody = $('div[data-page-modal="body"]');
modalBody.fadeOut(100);
modalBody.remove();
$("body").css("overflow", "auto");
$('script[data-page-modal="script"]').remove();
}
</script>
<th:block layout:fragment="script"></th:block>
</div>
</body>
</html>

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>시스템</title>
<script type="text/javascript" th:src="@{/js/jquery/jquery-3.7.1.min.js}"></script>
<link rel="shortcut icon" th:href="@{/images/favicon.ico}">
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<script type="text/javascript" th:src="@{/js/pagehelper.js}"></script>
<script type="text/javascript" th:src="@{/js/reqhelper.js}"></script>
<script type="text/javascript" th:src="@{/js/eventrouter.js}"></script>
</head>
<body>
<div class="login_wrap">
<div class="login_box">
<h1>시스템</h1>
<p class="login_subtit">환영합니다. 시스템에 로그인하세요.</p>
<form th:action="@{/login}" method="post" id="loginForm">
<p>
<label>아이디</label>
<input type="text" name="loginId" placeholder="이름 입력">
</p>
<p>
<label>비밀번호</label>
<input type="password" name="password" placeholder="암호 입력">
</p>
</form>
<button type="button" id="loginButton" class="btn_confirm btn_login">로그인</button>
</div>
</div>
<script type="text/javascript" th:inline="javascript">
const errorMessage = /*[[${errorMessage}]]*/ '';
if (errorMessage) {
PageHelper.showErrorModal(errorMessage);
}
$('#loginButton').click(function () {
if (!validate()) {
return;
}
$('#loginForm').submit();
function validate() {
const loginId = $('input[name="loginId"]').val();
const password = $('input[name="password"]').val();
if (!loginId) {
PageHelper.showErrorModal('아이디를 입력해주세요.');
return false;
}
if (!password) {
PageHelper.showErrorModal('비밀번호를 입력해주세요.');
return false;
}
return true;
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body>
<th:block th:fragment="gnb">
<h1 class="title_text">
관리자 시스템
</h1>
<p class="menu_right">
<a href="#">비밀번호 변경</a>
<a th:href="@{/logout}">로그아웃</a>
</p>
<a href="#none" class="nav_menu" id="toggleLink"><img th:src="@{/images/ico_menu.svg}" alt="메뉴 아이콘"></a>
</th:block>
</body>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<body>
<th:block th:fragment="lnb">
<nav class="menu_wrap flexitem">
<!--/*@thymesVar id="pathInfo" type="kr.co.bpsoft.settlement_system.backend.admin.web.advice.PathInfoControllerAdvice$PathInfo"*/-->
<h2>메뉴</h2>
<button class="closeButton"><img th:src="@{/images/ico_close.svg}"></button>
<th>
<a href="#"
class="nav_tit">
1 Depth
</a>
<ul class="nav_section">
<li>
<a>2 Depth</a>
</li>
</ul>
</th>
</nav>
</th:block>
</body>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>관리자 시스템</title>
<script type="text/javascript" th:src="@{/js/jquery/jquery-3.7.1.min.js}"></script>
<link rel="shortcut icon" th:href="@{/images/favicon.ico}">
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<link rel="stylesheet" th:href="@{/css/sub.css}"/>
<script type="text/javascript" th:src="@{/js/motion.js}"></script>
<script type="text/javascript" th:src="@{/js/pagehelper.js}"></script>
<script type="text/javascript" th:src="@{/js/reqhelper.js}"></script>
<script type="text/javascript" th:src="@{/js/eventrouter.js}"></script>
<th:block th:replace="~{fragment/csrf/csrf :: applyCsrf}"></th:block>
</head>
<body>
<header>
<th:block th:replace="~{main/gnb :: gnb}"></th:block>
</header>
<div class="wrapper flexbox">
<th:block th:replace="~{main/lnb :: lnb}"></th:block>
<div class="container flexitem">
<th:block layout:fragment="contents"/>
</div>
</div>
<th:block layout:fragment="script">
</th:block>
</body>
</html>

View File

@ -0,0 +1,13 @@
package com.bpgroup.poc.admin;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class AdminApplicationTests {
@Test
void contextLoads() {
}
}