diff --git a/build.gradle b/build.gradle index f145fb792..6bce95086 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:5.3.1' testImplementation 'org.hamcrest:hamcrest:2.2' diff --git a/src/main/java/roomescape/auth/AdminInterceptor.java b/src/main/java/roomescape/auth/AdminInterceptor.java new file mode 100644 index 000000000..a30a4a2aa --- /dev/null +++ b/src/main/java/roomescape/auth/AdminInterceptor.java @@ -0,0 +1,52 @@ +package roomescape.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; +import roomescape.domain.Member; +import roomescape.domain.MemberRepository; +import roomescape.util.JwtUtil; + +import java.util.Arrays; +import java.util.Optional; + +public class AdminInterceptor implements HandlerInterceptor { + + private final JwtUtil jwtUtil; + private final MemberRepository memberRepository; + + public AdminInterceptor(JwtUtil jwtUtil, MemberRepository memberRepository) { + this.jwtUtil = jwtUtil; + this.memberRepository = memberRepository; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + Optional token = extractToken(request.getCookies()); + if (token.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + Long memberId = jwtUtil.getMemberIdFromToken(token.get()); + Optional member = memberRepository.findById(memberId); + + if (member.isEmpty() || !"ADMIN".equals(member.get().getRole())) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + return true; + } + + private Optional extractToken(Cookie[] cookies) { + if (cookies == null) { + return Optional.empty(); + } + return Arrays.stream(cookies) + .filter(cookie -> "token".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } +} diff --git a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java b/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java new file mode 100644 index 000000000..deb6e918d --- /dev/null +++ b/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java @@ -0,0 +1,64 @@ +package roomescape.auth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import roomescape.domain.Member; +import roomescape.domain.MemberRepository; +import roomescape.dto.LoginMember; +import roomescape.util.JwtUtil; + +import java.util.Arrays; +import java.util.Optional; + +public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final JwtUtil jwtUtil; + private final MemberRepository memberRepository; + + public LoginMemberArgumentResolver(JwtUtil jwtUtil, MemberRepository memberRepository) { + this.jwtUtil = jwtUtil; + this.memberRepository = memberRepository; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + return null; + } + + Optional token = extractToken(request.getCookies()); + if (token.isEmpty()) { + return null; + } + + Long memberId = jwtUtil.getMemberIdFromToken(token.get()); + Member member = memberRepository.findById(memberId).orElse(null); + + if (member == null) { + return null; + } + return new LoginMember(member.getId(), member.getName(), member.getRole()); + } + + private Optional extractToken(Cookie[] cookies) { + if (cookies == null) { + return Optional.empty(); + } + return Arrays.stream(cookies) + .filter(cookie -> "token".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } +} diff --git a/src/main/java/roomescape/config/WebConfig.java b/src/main/java/roomescape/config/WebConfig.java new file mode 100644 index 000000000..50709c2e2 --- /dev/null +++ b/src/main/java/roomescape/config/WebConfig.java @@ -0,0 +1,35 @@ +package roomescape.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import roomescape.domain.MemberRepository; +import roomescape.auth.AdminInterceptor; +import roomescape.util.JwtUtil; +import roomescape.auth.LoginMemberArgumentResolver; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final JwtUtil jwtUtil; + private final MemberRepository memberRepository; + + public WebConfig(JwtUtil jwtUtil, MemberRepository memberRepository) { + this.jwtUtil = jwtUtil; + this.memberRepository = memberRepository; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginMemberArgumentResolver(jwtUtil, memberRepository)); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new AdminInterceptor(jwtUtil, memberRepository)) + .addPathPatterns("/admin/**"); + } +} diff --git a/src/main/java/roomescape/controller/LoginController.java b/src/main/java/roomescape/controller/LoginController.java new file mode 100644 index 000000000..8d7b48571 --- /dev/null +++ b/src/main/java/roomescape/controller/LoginController.java @@ -0,0 +1,43 @@ +package roomescape.controller; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +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.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import roomescape.dto.LoginMember; +import roomescape.dto.LoginRequest; +import roomescape.dto.MemberResponse; +import roomescape.service.LoginService; + +@RestController +public class LoginController { + + private final LoginService loginService; + + public LoginController(LoginService loginService) { + this.loginService = loginService; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { + String token = loginService.login(loginRequest.email(), loginRequest.password()); + + Cookie cookie = new Cookie("token", token); + cookie.setPath("/"); + cookie.setHttpOnly(true); + response.addCookie(cookie); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/login/check") + public ResponseEntity checkLogin(LoginMember loginMember) { + if (loginMember == null) { + throw new IllegalArgumentException("[ERROR] 인증되지 않은 사용자입니다."); + } + return ResponseEntity.ok(new MemberResponse(loginMember.name())); + } +} diff --git a/src/main/java/roomescape/controller/PageController.java b/src/main/java/roomescape/controller/PageController.java index bd2e5efbc..e6cdd5c7b 100644 --- a/src/main/java/roomescape/controller/PageController.java +++ b/src/main/java/roomescape/controller/PageController.java @@ -20,4 +20,14 @@ public String reservationPage() { public String timePage() { return "time"; } + + @GetMapping("/login") + public String loginPage() { + return "login"; + } + + @GetMapping("/admin") + public String adminPage() { + return "admin"; + } } diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java index 5e524095f..d89abace2 100644 --- a/src/main/java/roomescape/controller/ReservationController.java +++ b/src/main/java/roomescape/controller/ReservationController.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import roomescape.dto.LoginMember; import roomescape.dto.ReservationRequest; import roomescape.dto.ReservationResponse; import roomescape.dto.TimeRequest; @@ -32,8 +33,11 @@ public ResponseEntity> getReservations() { } @PostMapping("/reservations") - public ResponseEntity createReservation(@RequestBody ReservationRequest request) { - ReservationResponse reservation = reservationService.createReservation(request); + public ResponseEntity createReservation( + @RequestBody ReservationRequest request, + LoginMember loginMember + ) { + ReservationResponse reservation = reservationService.createReservation(request, loginMember); return ResponseEntity .created(URI.create("/reservations/" + reservation.id())) .body(reservation); diff --git a/src/main/java/roomescape/domain/Member.java b/src/main/java/roomescape/domain/Member.java new file mode 100644 index 000000000..032fe2ccc --- /dev/null +++ b/src/main/java/roomescape/domain/Member.java @@ -0,0 +1,31 @@ +package roomescape.domain; + +import java.util.Objects; + +public class Member { + private final Long id; + private final String email; + private final String password; + private final String name; + private final String role; + + public Member(Long id, String email, String password, String name, String role) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + this.role = role; + } + + public void checkPassword(String passwordToCompare) { + if (!Objects.equals(this.password, passwordToCompare)) { + throw new IllegalArgumentException("[ERROR] 비밀번호가 일치하지 않습니다."); + } + } + + public Long getId() { return id; } + public String getEmail() { return email; } + public String getPassword() { return password; } + public String getName() { return name; } + public String getRole() { return role; } +} diff --git a/src/main/java/roomescape/domain/MemberRepository.java b/src/main/java/roomescape/domain/MemberRepository.java new file mode 100644 index 000000000..681d732d4 --- /dev/null +++ b/src/main/java/roomescape/domain/MemberRepository.java @@ -0,0 +1,43 @@ +package roomescape.domain; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class MemberRepository { + private final JdbcTemplate jdbcTemplate; + + public MemberRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private final RowMapper memberRowMapper = (rs, rowNum) -> new Member( + rs.getLong("id"), + rs.getString("email"), + rs.getString("password"), + rs.getString("name"), + rs.getString("role") + ); + + public Optional findByEmail(String email) { + String sql = "SELECT id, email, password, name, role FROM member WHERE email = ?"; + try { + return Optional.ofNullable(jdbcTemplate.queryForObject(sql, memberRowMapper, email)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public Optional findById(Long id) { + String sql = "SELECT id, email, password, name, role FROM member WHERE id = ?"; + try { + return Optional.ofNullable(jdbcTemplate.queryForObject(sql, memberRowMapper, id)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/roomescape/dto/LoginMember.java b/src/main/java/roomescape/dto/LoginMember.java new file mode 100644 index 000000000..5c7c1d053 --- /dev/null +++ b/src/main/java/roomescape/dto/LoginMember.java @@ -0,0 +1,8 @@ +package roomescape.dto; + +public record LoginMember( + Long id, + String name, + String role +) { +} diff --git a/src/main/java/roomescape/dto/LoginRequest.java b/src/main/java/roomescape/dto/LoginRequest.java new file mode 100644 index 000000000..be0d16c6a --- /dev/null +++ b/src/main/java/roomescape/dto/LoginRequest.java @@ -0,0 +1,4 @@ +package roomescape.dto; + +public record LoginRequest(String email, String password) { +} diff --git a/src/main/java/roomescape/dto/MemberResponse.java b/src/main/java/roomescape/dto/MemberResponse.java new file mode 100644 index 000000000..da2d93c1b --- /dev/null +++ b/src/main/java/roomescape/dto/MemberResponse.java @@ -0,0 +1,4 @@ +package roomescape.dto; + +public record MemberResponse(String name) { +} diff --git a/src/main/java/roomescape/service/LoginService.java b/src/main/java/roomescape/service/LoginService.java new file mode 100644 index 000000000..ca281d3f4 --- /dev/null +++ b/src/main/java/roomescape/service/LoginService.java @@ -0,0 +1,26 @@ +package roomescape.service; + +import org.springframework.stereotype.Service; +import roomescape.domain.Member; +import roomescape.domain.MemberRepository; +import roomescape.util.JwtUtil; + +@Service +public class LoginService { + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + public LoginService(MemberRepository memberRepository, JwtUtil jwtUtil) { + this.memberRepository = memberRepository; + this.jwtUtil = jwtUtil; + } + + public String login(String email, String password) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("[ERROR] 아이디 또는 비밀번호가 일치하지 않습니다.")); + + member.checkPassword(password); + + return jwtUtil.createToken(member); + } +} diff --git a/src/main/java/roomescape/service/ReservationService.java b/src/main/java/roomescape/service/ReservationService.java index 47c64b371..f7e7f784d 100644 --- a/src/main/java/roomescape/service/ReservationService.java +++ b/src/main/java/roomescape/service/ReservationService.java @@ -2,10 +2,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import roomescape.domain.Reservation; import roomescape.domain.ReservationRepository; import roomescape.domain.Time; import roomescape.domain.TimeRepository; +import roomescape.dto.LoginMember; import roomescape.dto.ReservationRequest; import roomescape.dto.ReservationResponse; import roomescape.dto.TimeRequest; @@ -31,15 +33,22 @@ public ReservationService(ReservationRepository reservationRepository, } @Transactional - public ReservationResponse createReservation(ReservationRequest request) { - String validatedName = request.getValidatedName(); + public ReservationResponse createReservation(ReservationRequest request, LoginMember loginMember) { + String reservationName = request.name(); + if (!StringUtils.hasText(reservationName)) { + if (loginMember == null) { + throw new ReservationException("[ERROR] 예약자 이름을 입력해주세요 (비로그인 상태)."); + } + reservationName = loginMember.name(); + } + LocalDate parsedDate = request.getParsedDate(); Long timeId = request.timeId(); Time time = timeRepository.findById(timeId) .orElseThrow(() -> new ReservationException("[ERROR] 등록되지 않은 시간입니다.")); - Reservation newReservation = Reservation.create(validatedName, parsedDate, time); + Reservation newReservation = Reservation.create(reservationName, parsedDate, time); if (reservationRepository.existsByDateAndTimeId(newReservation.getDate(), newReservation.getTime().getId())) { throw new ReservationException("[ERROR] 이미 예약된 시간입니다."); diff --git a/src/main/java/roomescape/util/JwtUtil.java b/src/main/java/roomescape/util/JwtUtil.java new file mode 100644 index 000000000..b0b1b2600 --- /dev/null +++ b/src/main/java/roomescape/util/JwtUtil.java @@ -0,0 +1,41 @@ +package roomescape.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; +import roomescape.domain.Member; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; + +@Component +public class JwtUtil { + private final SecretKey secretKey; + + public JwtUtil() { + String secretKeyString = "Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E="; + this.secretKey = Keys.hmacShaKeyFor(secretKeyString.getBytes(StandardCharsets.UTF_8)); + } + + public String createToken(Member member) { + return Jwts.builder() + .setSubject(member.getId().toString()) + .claim("name", member.getName()) + .claim("role", member.getRole()) + .signWith(secretKey) + .compact(); + } + + public Long getMemberIdFromToken(String token) { + return Long.valueOf(parseClaims(token).getSubject()); + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 2282d5307..226516c23 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -12,3 +12,15 @@ CREATE TABLE reservation ( PRIMARY KEY (id), FOREIGN KEY (time_id) REFERENCES time(id) ); + +CREATE TABLE member ( + id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(60) NOT NULL, + name VARCHAR(50) NOT NULL, + role VARCHAR(10) NOT NULL, + PRIMARY KEY (id) +); + +INSERT INTO member (email, password, name, role) VALUES ('admin@email.com', 'password', '어드민', 'ADMIN'); +INSERT INTO member (email, password, name, role) VALUES ('brown@email.com', 'password', '브라운', 'USER'); diff --git a/src/main/resources/static/js/new-reservation.js b/src/main/resources/static/js/new-reservation.js deleted file mode 100644 index 520d0cf42..000000000 --- a/src/main/resources/static/js/new-reservation.js +++ /dev/null @@ -1,212 +0,0 @@ -let isEditing = false; -const RESERVATION_API_ENDPOINT = '/reservations'; -const TIME_API_ENDPOINT = '/times'; - -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('add-reservation').addEventListener('click', addEditableRow); - fetchReservations(); - fetchTimes(); -}); - -function fetchTimes() { - requestReadTimes() - .then(data => { - const timeSelectControl = createFormControl(data); - appendFormControlToDocument(timeSelectControl); - }) - .catch(error => console.error('Error fetching time:', error)); -} - -function createFormControl(timeData) { - const select = document.createElement('select'); - select.className = 'form-control'; - select.id = 'time-select'; - - const defaultOption = document.createElement('option'); - defaultOption.textContent = "시간 선택"; - select.appendChild(defaultOption); - - timeData.forEach(time => { - const option = document.createElement('option'); - option.value = time.id; - option.textContent = time.time; - select.appendChild(option); - }); - - return select; -} - -function appendFormControlToDocument(control) { - document.body.appendChild(control); -} - -function fetchReservations() { - requestRead() - .then(renderReservations) - .catch(error => console.error('Error fetching reservations:', error)); -} - -function renderReservations(data) { - const tableBody = document.getElementById('reservation-table-body'); - tableBody.innerHTML = ''; - - data.forEach(reservation => { - const row = tableBody.insertRow(); - insertReservationRow(row, reservation); - }); -} - -function insertReservationRow(row, reservation) { - ['id', 'name', 'date'].forEach((field, index) => { - row.insertCell(index).textContent = reservation[field]; - }); - - row.insertCell(3).textContent = reservation.time.time; - - const actionCell = row.insertCell(4); - actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); -} - -function createActionButton(label, className, eventListener) { - const button = document.createElement('button'); - button.textContent = label; - button.classList.add('btn', className, 'mr-2'); - button.addEventListener('click', eventListener); - return button; -} - -function addEditableRow() { - - if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 - - const tableBody = document.getElementById('reservation-table-body'); - const row = tableBody.insertRow(); - isEditing = true; - - createEditableFieldsFor(row); - addSaveAndCancelButtonsToRow(row); -} - -function createEditableFieldsFor(row) { - const nameInput = createInput('text'); - const dateInput = createInput('date'); - const timeDropdown = document.getElementById('time-select').cloneNode(true); - - const fields = ['', nameInput, dateInput, timeDropdown]; - - fields.forEach((field, index) => { - const cell = row.insertCell(index); - if (typeof field === 'string') { - cell.textContent = field; - } else { - cell.appendChild(field); - } - }); -} - -function addSaveAndCancelButtonsToRow(row) { - const actionCell = row.insertCell(4); - actionCell.appendChild(createActionButton('확인', 'btn-primary', saveRow)); - actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { - row.remove(); - isEditing = false; - })); -} - -function createInput(type) { - const input = document.createElement('input'); - input.type = type; - input.className = 'form-control'; - return input; -} - -function saveRow(event) { - const row = event.target.parentNode.parentNode; - const nameInput = row.querySelector('input[type="text"]'); - const dateInput = row.querySelector('input[type="date"]'); - const timeSelect = row.querySelector('select'); - - const reservation = { - name: nameInput.value, - date: dateInput.value, - time: timeSelect.value - }; - - requestCreate(reservation) - .then(data => updateRowWithReservationData(row, data)) - .catch(error => console.error('Error:', error)); - - isEditing = false; // isEditing 값을 false로 설정 -} - -function updateRowWithReservationData(row, data) { - const cells = row.cells; - cells[0].textContent = data.id; - cells[1].textContent = data.name; - cells[2].textContent = data.date; - cells[3].textContent = data.time.time; - - // 버튼 변경: 삭제 버튼으로 변경 - cells[4].innerHTML = ''; - cells[4].appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); - - isEditing = false; - - // Remove the editable input fields and just show the saved data - for (let i = 1; i <= 3; i++) { - const inputElement = cells[i].querySelector('input'); - if (inputElement) { - inputElement.remove(); - } - } -} - -function deleteRow(event) { - const row = event.target.closest('tr'); - const reservationId = row.cells[0].textContent; - - requestDelete(reservationId) - .then(() => row.remove()) - .catch(error => console.error('Error:', error)); -} - -function requestCreate(reservation) { - const requestOptions = { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(reservation) - }; - - return fetch(RESERVATION_API_ENDPOINT, requestOptions) - .then(response => { - if (response.status === 201) return response.json(); - throw new Error('Create failed'); - }); -} - -function requestRead() { - return fetch(RESERVATION_API_ENDPOINT) - .then(response => { - if (response.status === 200) return response.json(); - throw new Error('Read failed'); - }); -} - -function requestDelete(id) { - const requestOptions = { - method: 'DELETE', - }; - - return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) - .then(response => { - if (response.status !== 204) throw new Error('Delete failed'); - }); -} - -function requestReadTimes() { - return fetch(TIME_API_ENDPOINT) - .then(response => { - if (response.status === 200) return response.json(); - throw new Error('Read failed'); - }); -} diff --git a/src/main/resources/static/js/reservation.js b/src/main/resources/static/js/reservation.js deleted file mode 100644 index f8e6bf2b0..000000000 --- a/src/main/resources/static/js/reservation.js +++ /dev/null @@ -1,161 +0,0 @@ -let isEditing = false; -const RESERVATION_API_ENDPOINT = '/reservations'; - -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('add-reservation').addEventListener('click', addEditableRow); - fetchReservations(); -}); - -function fetchReservations() { - requestRead() - .then(renderReservations) - .catch(error => console.error('Error fetching reservations:', error)); -} - -function renderReservations(data) { - const tableBody = document.getElementById('reservation-table-body'); - tableBody.innerHTML = ''; - - data.forEach(reservation => { - const row = tableBody.insertRow(); - insertReservationRow(row, reservation); - }); -} - -function insertReservationRow(row, reservation) { - ['id', 'name', 'date', 'time'].forEach((field, index) => { - row.insertCell(index).textContent = reservation[field]; - }); - - const actionCell = row.insertCell(4); - actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); -} - -function createActionButton(label, className, eventListener) { - const button = document.createElement('button'); - button.textContent = label; - button.classList.add('btn', className, 'mr-2'); - button.addEventListener('click', eventListener); - return button; -} - -function addEditableRow() { - - if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 - - const tableBody = document.getElementById('reservation-table-body'); - const row = tableBody.insertRow(); - isEditing = true; - - createEditableFieldsFor(row); - addSaveAndCancelButtonsToRow(row); -} - -function createEditableFieldsFor(row) { - const fields = ['', createInput('text'), createInput('date'), createInput('time')]; - fields.forEach((field, index) => { - const cell = row.insertCell(index); - if (typeof field === 'string') { - cell.textContent = field; - } else { - cell.appendChild(field); - } - }); -} - -function addSaveAndCancelButtonsToRow(row) { - const actionCell = row.insertCell(4); - actionCell.appendChild(createActionButton('확인', 'btn-primary', saveRow)); - actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { - row.remove(); - isEditing = false; - })); -} - -function createInput(type) { - const input = document.createElement('input'); - input.type = type; - input.className = 'form-control'; - return input; -} - -function saveRow(event) { - const row = event.target.parentNode.parentNode; - const inputs = row.querySelectorAll('input'); - - const reservation = { - name: inputs[0].value, - date: inputs[1].value, - time: inputs[2].value - }; - - requestCreate(reservation) - .then(data => updateRowWithReservationData(row, data)) - .catch(error => console.error('Error:', error)); - - isEditing = false; // isEditing 값을 false로 설정 -} - -function updateRowWithReservationData(row, data) { - const cells = row.cells; - cells[0].textContent = data.id; - cells[1].textContent = data.name; - cells[2].textContent = data.date; - cells[3].textContent = data.time; - - // 버튼 변경: 삭제 버튼으로 변경 - cells[4].innerHTML = ''; - cells[4].appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); - - isEditing = false; - - // Remove the editable input fields and just show the saved data - for (let i = 1; i <= 3; i++) { - const inputElement = cells[i].querySelector('input'); - if (inputElement) { - inputElement.remove(); - } - } -} - -function deleteRow(event) { - const row = event.target.closest('tr'); - const reservationId = row.cells[0].textContent; - - requestDelete(reservationId) - .then(() => row.remove()) - .catch(error => console.error('Error:', error)); -} - -function requestCreate(reservation) { - const requestOptions = { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(reservation) - }; - - return fetch(RESERVATION_API_ENDPOINT, requestOptions) - .then(response => { - if (response.status === 201) return response.json(); - throw new Error('Create failed'); - }); -} - -function requestRead() { - return fetch(RESERVATION_API_ENDPOINT) - .then(response => { - if (response.status === 200) return response.json(); - throw new Error('Read failed'); - }); -} - -function requestDelete(id) { - const requestOptions = { - method: 'DELETE', - }; - - return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions) - .then(response => { - if (response.status !== 204) throw new Error('Delete failed'); - }); -} \ No newline at end of file diff --git a/src/main/resources/static/js/scripts.js b/src/main/resources/static/js/scripts.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/resources/static/js/time.js b/src/main/resources/static/js/time.js deleted file mode 100644 index e794464ec..000000000 --- a/src/main/resources/static/js/time.js +++ /dev/null @@ -1,157 +0,0 @@ -let isEditing = false; -const TIME_API_ENDPOINT = '/times'; - -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('add-time').addEventListener('click', addEditableRow); - fetchTimes(); -}); - -function addEditableRow() { - - if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음 - - const tableBody = document.getElementById('time-table-body'); - const row = tableBody.insertRow(); - isEditing = true; - - createEditableFieldsFor(row); - addSaveAndCancelButtonsToRow(row); -} - -function createEditableFieldsFor(row) { - const fields = ['', createInput('time')]; - fields.forEach((field, index) => { - const cell = row.insertCell(index); - if (typeof field === 'string') { - cell.textContent = field; - } else { - cell.appendChild(field); - } - }); -} - -function createInput(type) { - const input = document.createElement('input'); - input.type = type; - input.className = 'form-control'; - return input; -} - -function addSaveAndCancelButtonsToRow(row) { - const actionCell = row.insertCell(2); - actionCell.appendChild(createActionButton('확인', 'btn-primary', saveRow)); - actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => { - row.remove(); - isEditing = false; - })); -} - -function createActionButton(label, className, eventListener) { - const button = document.createElement('button'); - button.textContent = label; - button.classList.add('btn', className, 'mr-2'); - button.addEventListener('click', eventListener); - return button; -} - -function saveRow(event) { - const row = event.target.parentNode.parentNode; - const inputs = row.querySelectorAll('input'); - - const time = { - time: inputs[0].value, - }; - - requestCreate(time) - .then(data => updateRowWithTimeData(row, data)) - .catch(error => console.error('Error:', error)); - - isEditing = false; // isEditing 값을 false로 설정 -} - -function updateRowWithTimeData(row, data) { - const cells = row.cells; - cells[0].textContent = data.id; - cells[1].textContent = data.time; - - // 버튼 변경: 삭제 버튼으로 변경 - cells[2].innerHTML = ''; - cells[2].appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); - - isEditing = false; - - // Remove the editable input fields and just show the saved data - for (let i = 1; i <= 1; i++) { - const inputElement = cells[i].querySelector('input'); - if (inputElement) { - inputElement.remove(); - } - } -} - -function deleteRow(event) { - const row = event.target.closest('tr'); - const id = row.cells[0].textContent; - - requestDelete(id) - .then(() => row.remove()) - .catch(error => console.error('Error:', error)); -} - -function fetchTimes() { - requestRead() - .then(renderTimes) - .catch(error => console.error('Error fetching times:', error)); -} - -function renderTimes(data) { - const tableBody = document.getElementById('time-table-body'); - tableBody.innerHTML = ''; - - data.forEach(time => { - const row = tableBody.insertRow(); - insertTimeRow(row, time); - }); -} - -function insertTimeRow(row, time) { - ['id', 'time'].forEach((field, index) => { - row.insertCell(index).textContent = time[field]; - }); - - const actionCell = row.insertCell(2); - actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow)); -} - -function requestCreate(time) { - const requestOptions = { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(time) - }; - - return fetch(TIME_API_ENDPOINT, requestOptions) - .then(response => { - if (response.status === 201) return response.json(); - throw new Error('Create failed'); - }); -} - -function requestRead() { - return fetch(TIME_API_ENDPOINT) - .then(response => { - if (response.status === 200) return response.json(); - throw new Error('Read failed'); - }); -} - -function requestDelete(id) { - const requestOptions = { - method: 'DELETE', - }; - - return fetch(`${TIME_API_ENDPOINT}/${id}`, requestOptions) - .then(response => { - if (response.status !== 204) throw new Error('Delete failed'); - }); -} \ No newline at end of file diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 000000000..8c2de6adb --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,59 @@ + + + + + + 방탈출 어드민 + + + + + + + + +
+

방탈출 어드민

+ +
+ + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 000000000..5fbcebbf7 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,64 @@ + + + + + + Login + + + + + + + + +
+

Login

+
+
+ +
+
+ +
+
+ + +
+
+
+ + + + diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index 5e8f24abf..b49e5d56b 100644 --- a/src/test/java/roomescape/MissionStepTest.java +++ b/src/test/java/roomescape/MissionStepTest.java @@ -2,6 +2,8 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,12 +25,143 @@ @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) public class MissionStepTest { - @Autowired + @Autowired(required = false) private ReservationController reservationController; + private String createToken(String email, String password) { + Map loginParams = new HashMap<>(); + loginParams.put("email", email); + loginParams.put("password", password); + + return RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(loginParams) + .when().post("/login") + .then().log().all() + .statusCode(200) + .extract() + .cookie("token"); + } + + @Test + @DisplayName("로그인한 사용자가 이름 없이 예약을 생성하면 자신의 이름으로 예약된다") + void createReservationWithLoggedInUserName() { + // given: 예약을 위한 시간 생성 및 로그인 토큰 발급 + Map timeParams = new HashMap<>(); + timeParams.put("time", "11:00"); + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(timeParams) + .when().post("/times") + .then().log().all() + .statusCode(201); + + String token = createToken("admin@email.com", "password"); + + // when: 로그인 상태에서 'name' 없이 예약을 요청 + Map paramsWithoutName = new HashMap<>(); + paramsWithoutName.put("date", "2025-08-05"); + paramsWithoutName.put("timeId", 1L); + + ExtractableResponse response = RestAssured.given().log().all() + .body(paramsWithoutName) + .cookie("token", token) + .contentType(ContentType.JSON) + .when().post("/reservations") + .then().log().all() + .extract(); + + // then: 로그인한 사용자('어드민')의 이름으로 예약이 생성됨 + assertThat(response.statusCode()).isEqualTo(201); + assertThat(response.jsonPath().getString("name")).isEqualTo("어드민"); + } + + @Test + @DisplayName("로그인한 사용자가 이름을 직접 입력하면 해당 이름으로 예약된다") + void createReservationWithSpecifiedNameWhileLoggedIn() { + // given: 예약을 위한 시간 생성 및 로그인 토큰 발급 + Map timeParams = new HashMap<>(); + timeParams.put("time", "11:00"); + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(timeParams) + .when().post("/times") + .then().log().all() + .statusCode(201); + + String token = createToken("admin@email.com", "password"); + + // when: 로그인 상태에서 'name'을 직접 입력하여 예약을 요청 + Map paramsWithName = new HashMap<>(); + paramsWithName.put("name", "브라운"); + paramsWithName.put("date", "2025-08-06"); + paramsWithName.put("timeId", 1L); + + ExtractableResponse response = RestAssured.given().log().all() + .body(paramsWithName) + .cookie("token", token) + .contentType(ContentType.JSON) + .when().post("/reservations") + .then().log().all() + .extract(); + + // then: 직접 입력한 이름('브라운')으로 예약이 생성됨 + assertThat(response.statusCode()).isEqualTo(201); + assertThat(response.jsonPath().getString("name")).isEqualTo("브라운"); + } + + @Test + @DisplayName("로그인에 성공하고, 발급된 토큰으로 사용자 정보를 정상적으로 조회한다") + void loginAndCheckUserStatus() { + // given: 사용자가 로그인을 시도하여 토큰을 발급받는다. + String token = createToken("admin@email.com", "password"); + assertThat(token).isNotBlank(); + + // when: 발급받은 토큰으로 사용자 정보 조회를 요청한다. + // then: 요청이 성공하고, 올바른 사용자 이름이 반환된다. + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .cookie("token", token) + .when().get("/login/check") + .then().log().all() + .statusCode(200) + .body("name", is("어드민")); + } + + @Test + @DisplayName("일반 사용자는 어드민 페이지에 접근할 수 없다") + void nonAdminUserCannotAccessAdminPage() { + // given: 일반 사용자(USER)로 로그인하여 토큰 발급 + String userToken = createToken("brown@email.com", "password"); + + // when: 어드민 페이지에 접근을 시도한다. + // then: 401 Unauthorized 응답을 받는다. + RestAssured.given().log().all() + .cookie("token", userToken) + .get("/admin") + .then().log().all() + .statusCode(401); + } + + @Test + @DisplayName("어드민 사용자는 어드민 페이지에 접근할 수 있다") + void adminUserCanAccessAdminPage() { + // given: 어드민 사용자(ADMIN)로 로그인하여 토큰 발급 + String adminToken = createToken("admin@email.com", "password"); + + // when: 어드민 페이지에 접근을 시도한다. + // then: 200 OK 응답을 받는다. + RestAssured.given().log().all() + .cookie("token", adminToken) + .get("/admin") + .then().log().all() + .statusCode(200); + } + @Test @DisplayName("홈 페이지 접근 시 정상 응답을 반환한다") void getHomePageReturnsOk() { + // when RestAssured.given().log().all() .when().get("/") .then().log().all() @@ -38,11 +171,13 @@ void getHomePageReturnsOk() { @Test @DisplayName("예약 조회 페이지와 API가 정상적으로 동작한다") void getReservationPageAndList() { + // when RestAssured.given().log().all() .when().get("/reservation") .then().log().all() .statusCode(200); + // then RestAssured.given().log().all() .when().get("/reservations") .then().log().all() @@ -53,6 +188,7 @@ void getReservationPageAndList() { @Test @DisplayName("예약을 생성하고 조회하고 삭제할 수 있다") void createReadAndDeleteReservation() { + // given Map timeParams = new HashMap<>(); timeParams.put("time", "17:00"); RestAssured.given().log().all() @@ -62,31 +198,37 @@ void createReadAndDeleteReservation() { .then().log().all() .statusCode(201); + // when Map reservationParams = new HashMap<>(); reservationParams.put("name", "오찌"); reservationParams.put("date", LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE)); reservationParams.put("timeId", 1L); - RestAssured.given().log().all() + ExtractableResponse createResponse = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(reservationParams) .when().post("/reservations") .then().log().all() - .statusCode(201) - .header("Location", "/reservations/1") - .body("id", is(1)); + .extract(); + + // then + assertThat(createResponse.statusCode()).isEqualTo(201); + assertThat(createResponse.header("Location")).isEqualTo("/reservations/1"); + // when RestAssured.given().log().all() .when().get("/reservations") .then().log().all() .statusCode(200) .body("size()", is(1)); + // when RestAssured.given().log().all() .when().delete("/reservations/1") .then().log().all() .statusCode(204); + // then RestAssured.given().log().all() .when().get("/reservations") .then().log().all() @@ -97,10 +239,13 @@ void createReadAndDeleteReservation() { @Test @DisplayName("유효하지 않은 예약 생성 또는 삭제 시 에러를 반환한다") void createOrDeleteReservationWithInvalidInputReturnsError() { + // given Map params = new HashMap<>(); params.put("name", "브라운"); params.put("date", ""); + params.put("timeId", 1L); + // when & then RestAssured.given().log().all() .contentType(ContentType.JSON) .body(params) @@ -108,8 +253,9 @@ void createOrDeleteReservationWithInvalidInputReturnsError() { .then().log().all() .statusCode(400); + // when & then RestAssured.given().log().all() - .when().delete("/reservations/1") + .when().delete("/reservations/999") .then().log().all() .statusCode(400); } @@ -117,9 +263,11 @@ void createOrDeleteReservationWithInvalidInputReturnsError() { @Test @DisplayName("시간을 생성하고 조회하고 삭제할 수 있다") void createReadAndDeleteTime() { + // given Map params = new HashMap<>(); params.put("time", "10:00"); + // when RestAssured.given().log().all() .contentType(ContentType.JSON) .body(params) @@ -128,17 +276,20 @@ void createReadAndDeleteTime() { .statusCode(201) .header("Location", "/times/1"); + // then RestAssured.given().log().all() .when().get("/times") .then().log().all() .statusCode(200) .body("size()", is(1)); + // when RestAssured.given().log().all() .when().delete("/times/1") .then().log().all() .statusCode(204); + // then RestAssured.given().log().all() .when().get("/times") .then().log().all() @@ -149,11 +300,13 @@ void createReadAndDeleteTime() { @Test @DisplayName("등록되지 않은 시간으로 예약을 시도하면 400 에러를 반환한다") void createReservationWithUnregisteredTimeFails() { + // given Map reservation = new HashMap<>(); reservation.put("name", "브라운"); reservation.put("date", "2025-08-05"); reservation.put("timeId", 999L); + // when & then RestAssured.given().log().all() .contentType(ContentType.JSON) .body(reservation) @@ -165,6 +318,12 @@ void createReservationWithUnregisteredTimeFails() { @Test @DisplayName("컨트롤러는 JdbcTemplate에 직접 의존하지 않아야 한다") void controllerShouldNotDependOnJdbcTemplate() { + // given + if (reservationController == null) { + return; + } + + // when boolean isJdbcTemplateInjected = false; for (Field field : reservationController.getClass().getDeclaredFields()) { if (field.getType().equals(JdbcTemplate.class)) { @@ -172,6 +331,8 @@ void controllerShouldNotDependOnJdbcTemplate() { break; } } + + // then assertThat(isJdbcTemplateInjected).isFalse(); } }