This commit is contained in:
mindol1004
2024-10-14 14:18:34 +09:00
parent bf50ab2e21
commit 28bdb3adf9
28 changed files with 317 additions and 40 deletions

View File

@@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.spring.domain.user.dto.ChangePasswordRequest;
import com.spring.domain.user.dto.SignUpRequest;
import com.spring.domain.user.service.ChangePasswordService;
import com.spring.domain.user.service.SignUpService;
import lombok.RequiredArgsConstructor;
@@ -20,6 +22,7 @@ import lombok.RequiredArgsConstructor;
public class SignApi {
private final SignUpService signUpService;
private final ChangePasswordService changePasswordService;
@GetMapping("/conflict/{userId}")
public boolean isConflictUserId(@PathVariable String userId) {
@@ -31,4 +34,9 @@ public class SignApi {
signUpService.signUp(request);
}
@PostMapping("/change-password")
public void changePassword(@RequestBody @Valid ChangePasswordRequest request) {
changePasswordService.changePassword(request);
}
}

View File

@@ -1,5 +1,7 @@
package com.spring.domain.user.api;
import java.util.List;
import javax.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
@@ -7,21 +9,21 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.spring.domain.user.dto.PasswordChangeRequest;
import com.spring.domain.user.service.PasswordChangeService;
import com.spring.domain.user.dto.ChangeUserRoleApproveRequest;
import com.spring.domain.user.service.UserManagementService;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class PasswordChangeApi {
public class UserManagementApi {
private final PasswordChangeService passwordChangeService;
private final UserManagementService userManagementService;
@PostMapping("/password-change")
public void passwordChange(@RequestBody @Valid PasswordChangeRequest request) {
passwordChangeService.changePassword(request);
@PostMapping("/change-role-approve")
public void signUp(@RequestBody @Valid List<ChangeUserRoleApproveRequest> requests) {
userManagementService.changeRoleApprove(requests);
}
}

View File

@@ -7,7 +7,7 @@ import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class PasswordChangeRequest {
public class ChangePasswordRequest {
@NotBlank(message = "사용자ID는 필수값 입니다.")
private final String userId;

View File

@@ -0,0 +1,25 @@
package com.spring.domain.user.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import com.spring.common.validation.EnumValid;
import com.spring.domain.user.entity.AgentUserRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class ChangeUserRoleApproveRequest {
@NotBlank(message = "사용자ID는 필수값 입니다.")
private final String userId;
@EnumValid(target = AgentUserRole.class, message = "권한은 필수값 입니다.")
private final AgentUserRole userRole;
@NotNull(message = "승인 여부는 필수값 입니다.")
private final boolean isApproved;
}

View File

@@ -1,5 +1,6 @@
package com.spring.domain.user.dto;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import com.spring.common.validation.EnumValid;
@@ -23,6 +24,10 @@ public class SignUpRequest {
@NotBlank(message = "사용자명은 필수값 입니다.")
private String userName;
@Email(message = "EMAIL형식이 잘못 되었습니다.")
@NotBlank(message = "EMAIL은 필수값 입니다.")
private String email;
@EnumValid(target = AgentUserRole.class, message = "올바른 값을 입력해주세요.")
private AgentUserRole userRole;
@@ -35,6 +40,8 @@ public class SignUpRequest {
.userId(userId)
.userPassword(userPassword)
.userName(userName)
.email(email)
.isApproved(false)
.userRole(userRole)
.build();
}

View File

@@ -0,0 +1,17 @@
package com.spring.domain.user.dto;
import com.spring.domain.user.entity.AgentUserRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public class UserManagementResponse {
private final String userId;
private final String userName;
private final String email;
private final AgentUserRole userRole;
}

View File

@@ -45,22 +45,38 @@ public class AgentUser implements UserPrincipal {
@Column(name = "USER_NAME", nullable = false, length = 50)
private String userName;
@Column(name = "EMAIL", nullable = false, length = 100)
private String email;
@Column(name = "IS_APPROVED", nullable = false)
private boolean isApproved;
@Enumerated(EnumType.STRING)
@Column(name = "USER_ROLE", nullable = false, length = 50)
private AgentUserRole userRole;
@Builder
public AgentUser(String userId, String userPassword, String userName, AgentUserRole userRole) {
public AgentUser(String userId, String userPassword, String userName, AgentUserRole userRole, String email, boolean isApproved) {
this.userId = userId;
this.userPassword = userPassword;
this.userName = userName;
this.userRole = userRole;
this.email = email;
this.isApproved = isApproved;
}
public void changePassword(String newPassword) {
this.userPassword = newPassword;
}
public void changeUserRole(AgentUserRole userRole) {
this.userRole = userRole;
}
public void changeApproved(boolean isApproved) {
this.isApproved = isApproved;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.stream(AgentUserRole.values())

View File

@@ -12,9 +12,9 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum AgentUserRole {
ROLE_USER("ROLE_USER", "사용"),
ROLE_SUPER("ROLE_SUPER", "슈퍼관리"),
ROLE_ADMIN("ROLE_ADMIN", "관리자"),
ROLE_ANONYMOUS("ROLE_ANONYMOUS", "익명사용자");
ROLE_USER("ROLE_USER", "사용자");
@JsonValue
private final String role;

View File

@@ -4,7 +4,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.dto.PasswordChangeRequest;
import com.spring.domain.user.dto.ChangePasswordRequest;
import com.spring.domain.user.entity.AgentUser;
import com.spring.domain.user.error.PasswordSameException;
import com.spring.domain.user.error.UserNotFoundException;
@@ -14,13 +14,13 @@ import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class PasswordChangeService {
public class ChangePasswordService {
private final AgentUserRepository agentUserRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void changePassword(PasswordChangeRequest request) {
public void changePassword(ChangePasswordRequest request) {
AgentUser user = agentUserRepository.findByUserId(request.getUserId()).orElseThrow(UserNotFoundException::new);
if (passwordEncoder.matches(request.getNewPassword(), user.getPassword())) {
throw new PasswordSameException();

View File

@@ -0,0 +1,30 @@
package com.spring.domain.user.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.dto.ChangeUserRoleApproveRequest;
import com.spring.domain.user.entity.AgentUser;
import com.spring.domain.user.error.UserNotFoundException;
import com.spring.domain.user.repository.AgentUserRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserManagementService {
private final AgentUserRepository agentUserRepository;
@Transactional
public void changeRoleApprove(List<ChangeUserRoleApproveRequest> requests) {
for (ChangeUserRoleApproveRequest request : requests) {
AgentUser user = agentUserRepository.findByUserId(request.getUserId()).orElseThrow(UserNotFoundException::new);
user.changeUserRole(request.getUserRole());
user.changeApproved(request.isApproved());
}
}
}

View File

@@ -12,12 +12,13 @@ import com.spring.domain.user.repository.AgentUserRepository;
import com.spring.infra.security.domain.UserPrincipal;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
import com.spring.infra.security.service.UserAuthenticationService;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserPrincipalService implements UserDetailsService {
public class UserPrincipalService implements UserDetailsService, UserAuthenticationService {
private final AgentUserRepository agentUserRepository;
@@ -29,7 +30,8 @@ public class UserPrincipalService implements UserDetailsService {
}
@Transactional(readOnly = true)
public UserPrincipal getUser(String key) {
@Override
public UserPrincipal getUserDetails(String key) {
return agentUserRepository.findById(UUID.fromString(key))
.orElseThrow(UserNotFoundException::new);
}

View File

@@ -77,7 +77,7 @@ public class QuartzConfig {
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setJobFactory(jobFactory);
factory.setAutoStartup(true);
factory.setAutoStartup(false);
factory.setWaitForJobsToCompleteOnShutdown(true);
return factory;
}

View File

@@ -12,7 +12,7 @@ public enum PermittedURI {
FAVICON_URI("/favicon.ico"),
USER_CONFLICT_URI("/api/user/conflict/{userId}"),
USER_SIGN_UP("/api/user/sign-up"),
PASSWOD_CHANGE("/api/user/password-change"),
PASSWOD_CHANGE("/api/user/change-password"),
USER_SIGN_IN("/sign-in"),
USER_SIGN_OUT("/sign-out");

View File

@@ -17,6 +17,7 @@ public enum SecurityExceptionRule implements ErrorRule {
USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "사용자 인증에 실패 하였습니다."),
USER_NOT_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 틀립니다."),
USER_FORBIDDEN(HttpStatus.FORBIDDEN, "사용자 권한이 없습니다."),
USER_NOT_APPROVED(HttpStatus.FORBIDDEN, "사용자가 승인되지 않았습니다."),
JWT_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 잘못되었습니다."),
JWT_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "토큰 정보가 없습니다."),
SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."),

View File

@@ -17,6 +17,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spring.domain.user.entity.AgentUser;
import com.spring.infra.security.config.PermittedURI;
import com.spring.infra.security.dto.SignInRequest;
import com.spring.infra.security.error.SecurityAuthException;
@@ -47,7 +48,11 @@ public class AuthenticationProcessingFilter extends AbstractAuthenticationProces
throw new SecurityAuthException(SecurityExceptionRule.USER_BAD_REQUEST);
}
var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword());
return this.getAuthenticationManager().authenticate(token);
var authentication = this.getAuthenticationManager().authenticate(token);
if (!((AgentUser) authentication.getPrincipal()).isApproved()) {
throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_APPROVED);
}
return authentication;
}
private boolean isValidRequestType(HttpServletRequest request) {

View File

@@ -98,7 +98,7 @@ public final class JwtAuthenticationFilter extends OncePerRequestFilter {
return Optional.ofNullable(request.getHeader(headerName))
.filter(token -> token.substring(0, 7).equalsIgnoreCase(JwtTokenRule.BEARER_PREFIX.getValue()))
.map(token -> token.substring(7))
.orElse(null);
.orElse(jwtTokenService.resolveTokenFromCookie(request, JwtTokenRule.ACCESS_PREFIX));
}
}

View File

@@ -12,6 +12,9 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import com.spring.infra.security.config.PermittedURI;
import com.spring.infra.security.config.SecurityURI;
public class RedirectIfAuthenticatedFilter extends OncePerRequestFilter {
@Override
@@ -22,8 +25,8 @@ public class RedirectIfAuthenticatedFilter extends OncePerRequestFilter {
) throws ServletException, IOException {
String requestURI = request.getRequestURI();
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && "/".equals(requestURI)) {
response.sendRedirect("/dashboard");
if (auth != null && auth.isAuthenticated() && PermittedURI.ROOT_URI.getUri().equals(requestURI)) {
response.sendRedirect(SecurityURI.REDIRECT_URI.getUri());
return;
}
filterChain.doFilter(request, response);

View File

@@ -1,6 +1,7 @@
package com.spring.infra.security.jwt;
import java.security.Key;
import java.time.Duration;
import java.util.List;
import java.util.stream.Collectors;
@@ -17,11 +18,11 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.spring.domain.user.service.UserPrincipalService;
import com.spring.infra.security.domain.JwtUserPrincipal;
import com.spring.infra.security.error.SecurityAuthException;
import com.spring.infra.security.error.SecurityExceptionRule;
import com.spring.infra.security.service.RefreshTokenService;
import com.spring.infra.security.service.UserAuthenticationService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
@@ -39,25 +40,27 @@ public class JwtTokenService {
private final JwtTokenUtil jwtTokenUtil;
private final JwtTokenGenerator jwtTokenGenerator;
private final UserPrincipalService userPrincipalService;
private final UserAuthenticationService userAuthenticationService;
private final RefreshTokenService refreshTokenService;
private final Key accessSecretKey;
private final Key refreshSecretKey;
private final long accessExpiration;
private final long refreshExpiration;
public JwtTokenService(
JwtTokenUtil jwtTokenUtil,
JwtTokenGenerator jwtTokenGenerator,
UserPrincipalService userPrincipalService,
UserAuthenticationService userAuthenticationService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties
) {
this.jwtTokenUtil = jwtTokenUtil;
this.jwtTokenGenerator = jwtTokenGenerator;
this.userPrincipalService = userPrincipalService;
this.userAuthenticationService = userAuthenticationService;
this.refreshTokenService = refreshTokenService;
this.accessSecretKey = jwtTokenUtil.getSigningKey(jwtProperties.getAccessToken().getSecret());
this.refreshSecretKey = jwtTokenUtil.getSigningKey(jwtProperties.getRefreshToken().getSecret());
this.accessExpiration = jwtProperties.getAccessToken().getExpiration();
this.refreshExpiration = jwtProperties.getRefreshToken().getExpiration();
}
@@ -70,6 +73,8 @@ public class JwtTokenService {
*/
public String generateAccessToken(HttpServletResponse response, Authentication authentication) {
String accessToken = jwtTokenGenerator.generateAccessToken(authentication);
ResponseCookie cookie = setTokenToCookie(JwtTokenRule.ACCESS_PREFIX.getValue(), accessToken, accessExpiration);
response.addHeader(JwtTokenRule.JWT_ISSUE_HEADER.getValue(), cookie.toString());
response.setHeader(HttpHeaders.AUTHORIZATION, JwtTokenRule.BEARER_PREFIX.getValue() + accessToken);
return accessToken;
}
@@ -97,13 +102,13 @@ public class JwtTokenService {
* @param maxAgeSeconds 쿠키 유효 시간(초)
* @return 생성된 ResponseCookie 객체
*/
private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) {
private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeMinutes) {
return ResponseCookie.from(tokenPrefix, token)
.path("/")
.maxAge(maxAgeSeconds * 60)
.maxAge(Duration.ofMinutes(maxAgeMinutes))
.httpOnly(true)
.sameSite("Lax")
.secure(false)
.sameSite("None")
.secure(true)
.build();
}
@@ -178,7 +183,7 @@ public class JwtTokenService {
* @return 생성된 Authentication 객체
*/
public Authentication getAuthentication(String token) {
UserDetails user = userPrincipalService.getUser(getUserPk(token));
UserDetails user = userAuthenticationService.getUserDetails(getUserPk(token));
return new UsernamePasswordAuthenticationToken(user, "", null);
}
@@ -229,7 +234,9 @@ public class JwtTokenService {
* @param response HTTP 응답 객체
*/
public void deleteCookie(HttpServletResponse response) {
Cookie accessCookie = jwtTokenUtil.resetToken(JwtTokenRule.ACCESS_PREFIX);
Cookie refreshCookie = jwtTokenUtil.resetToken(JwtTokenRule.REFRESH_PREFIX);
response.addCookie(accessCookie);
response.addCookie(refreshCookie);
}

View File

@@ -17,7 +17,6 @@ import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j;
/**
* JWT 토큰 관련 유틸리티 기능을 제공하는 클래스입니다.
@@ -27,7 +26,6 @@ import lombok.extern.slf4j.Slf4j;
* @author mindol
* @version 1.0
*/
@Slf4j
@Component
public class JwtTokenUtil {

View File

@@ -0,0 +1,7 @@
package com.spring.infra.security.service;
import org.springframework.security.core.userdetails.UserDetails;
public interface UserAuthenticationService {
UserDetails getUserDetails(String key);
}

View File

@@ -1,5 +1,8 @@
package com.spring.web.controller;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -12,8 +15,11 @@ import com.spring.domain.user.entity.AgentUserRole;
public class SignController {
@GetMapping
public String signIn(Model model) {
model.addAttribute("roles", AgentUserRole.values());
public String signIn(Model model) {
List<AgentUserRole> roles = List.of(AgentUserRole.values()).stream()
.filter(role -> !role.getRole().equals(AgentUserRole.ROLE_SUPER.name()))
.collect(Collectors.toList());
model.addAttribute("roles", roles);
return "pages/sign/sign-in";
}

View File

@@ -0,0 +1,26 @@
package com.spring.web.controller;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.spring.domain.user.entity.AgentUserRole;
@Controller
@RequestMapping("/user")
public class UserController {
@GetMapping("/management")
public String management(Model model) {
List<AgentUserRole> roles = List.of(AgentUserRole.values()).stream()
.filter(role -> !role.getRole().equals(AgentUserRole.ROLE_SUPER.name()))
.collect(Collectors.toList());
model.addAttribute("roles", roles);
return "pages/user/user-management";
}
}

View File

@@ -42,18 +42,18 @@ spring:
jpa:
open-in-view: false
database-platform: org.hibernate.dialect.H2Dialect
#show-sql: true
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
"[format_sql]": true # 쿼리 로그 포맷 (정렬)
"[show_sql]": false # 쿼리 로그 출력
#"[show_sql]": true # 쿼리 로그 출력
"[highlight_sql]": true # 쿼리 하이라이트
"[use_sql_comments]": true # SQL 주석 사용
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
batch:
job:
@@ -133,6 +133,7 @@ logging:
level:
org:
hibernate:
SQL: DEBUG
SQL: debug
type:
descriptor: TRACE
descriptor:
sql: trace

View File

@@ -25,7 +25,7 @@ const signService = {
},
changePassword: async (userId, newPassword) => {
const response = await apiClient.post('/api/user/password-change', { userId, newPassword });
const response = await apiClient.post('/api/user/change-password', { userId, newPassword });
return response.data.data;
}

View File

@@ -0,0 +1,18 @@
import apiClient from '../common/axios-instance.js';
const userService = {
getUserList: async () => {
const response = await apiClient.get('/api/user');
return response.data;
},
changeRoleApprove: async (users) => {
await apiClient.put('/api/user/change-role-approve', users);
},
deleteUser: async (userId) => {
await apiClient.delete(`/api/user/${userId}`);
}
};
export default userService;

View File

@@ -0,0 +1,59 @@
import userService from '../../apis/user-api.js';
let users = []; // 사용자 목록을 저장할 배열
document.addEventListener('DOMContentLoaded', () => {
loadUserList();
document.getElementById('saveChanges').addEventListener('click', saveChanges);
});
const loadUserList = async () => {
users = await userService.getUserList();
const userTableBody = document.getElementById('userTableBody');
userTableBody.innerHTML = ''; // 기존 내용 초기화
users.forEach(user => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${user.userId}</td>
<td>${user.email}</td>
<td>${user.userName}</td>
<td>
<select class="form-select" data-user-id="${user.id}" data-user-role="${user.userRole}">
<option value="USER" ${user.userRole === 'USER' ? 'selected' : ''}>USER</option>
<option value="ADMIN" ${user.userRole === 'ADMIN' ? 'selected' : ''}>ADMIN</option>
</select>
</td>
<td>
<input type="checkbox" ${user.isApproved ? 'checked' : ''} data-user-id="${user.id}" data-user-approved="${user.isApproved}">
</td>
<td>
<button class="btn btn-danger" onclick="deleteUser('${user.id}')">삭제</button>
</td>
`;
userTableBody.appendChild(row);
});
};
const saveChanges = async () => {
const updatedUsers = users.map(user => {
const selectElement = document.querySelector(`select[data-user-id="${user.id}"]`);
const checkboxElement = document.querySelector(`input[data-user-id="${user.id}"]`);
return {
id: user.id,
role: selectElement.value,
isApproved: checkboxElement.checked
};
});
await userService.updateUsers(updatedUsers); // 배열로 사용자 정보를 전송
alert('모든 변경 사항이 저장되었습니다.');
loadUserList(); // 사용자 목록 새로 고침
};
const deleteUser = async (userId) => {
await userService.deleteUser(userId);
alert('사용자가 삭제되었습니다.');
loadUserList(); // 사용자 목록 새로 고침
};

View File

@@ -79,6 +79,12 @@
<input type="text" class="form-control" id="userName" name="userName" placeholder="사용자명" required>
</div>
</div>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
<input type="email" class="form-control" id="email" name="email" placeholder="이메일" required>
</div>
</div>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout}"
layout:fragment="content" lang="ko" xml:lang="ko">
<head>
<title>User Management</title>
</head>
<body>
<section>
<div class="container">
<h2 class="mt-4">회원 관리</h2>
<table class="table table-striped mt-3">
<thead>
<tr>
<th>아이디</th>
<th>이메일</th>
<th>사용자명</th>
<th>권한</th>
<th>승인 여부</th>
<th>작업</th>
</tr>
</thead>
<tbody id="userTableBody">
</tbody>
</table>
<button class="btn btn-primary" id="saveChanges">저장</button>
</div>
</section>
<script type="module" th:src="@{/js/pages/user/user-management.js}" defer></script>
</body>
</html>