package com.studyolle.modules.account;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.studyolle.modules.account.form.NicknameForm;
import com.studyolle.modules.account.form.Notifications;
import com.studyolle.modules.account.form.PasswordForm;
import com.studyolle.modules.account.form.Profile;
import com.studyolle.modules.tag.Tag;
import com.studyolle.modules.zone.Zone;
import com.studyolle.modules.account.validator.NicknameValidator;
import com.studyolle.modules.account.validator.PasswordFormValidator;
import com.studyolle.modules.tag.TagForm;
import com.studyolle.modules.tag.TagRepository;
import com.studyolle.modules.tag.TagService;
import com.studyolle.modules.zone.ZoneForm;
import com.studyolle.modules.zone.ZoneRepository;
import lombok.RequiredArgsConstructor;
import org.modelmapper.ModelMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static com.studyolle.modules.account.SettingsController.ROOT;
import static com.studyolle.modules.account.SettingsController.SETTINGS;
@Controller
@RequestMapping(ROOT+SETTINGS)
@RequiredArgsConstructor
public class SettingsController {
static final StringROOT= "/";
static final StringSETTINGS= "settings";
static final StringPROFILE= "/profile";
static final StringPASSWORD= "/password";
static final StringNOTIFICATIONS= "/notifications";
static final StringACCOUNT= "/account";
static final StringTAGS= "/tags";
static final StringZONES= "/zones";
private final AccountService accountService;
private final ModelMapper modelMapper;
private final NicknameValidator nicknameValidator;
private final TagService tagService;
private final TagRepository tagRepository;
private final ZoneRepository zoneRepository;
private final ObjectMapper objectMapper;
@InitBinder("passwordForm")
public void passwordFormInitBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(new PasswordFormValidator());
}
@InitBinder("nicknameForm")
public void nicknameFormInitBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(nicknameValidator);
}
@GetMapping(PROFILE)
public String updateProfileForm(@CurrentAccount Account account, Model model) {
model.addAttribute(account);
model.addAttribute(modelMapper.map(account, Profile.class));
returnSETTINGS+PROFILE;
}
@PostMapping(PROFILE)
public String updateProfile(@CurrentAccount Account account, @Valid Profile profile, Errors errors,
Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
returnSETTINGS+PROFILE;
}
accountService.updateProfile(account, profile);
attributes.addFlashAttribute("message", "프로필을 수정했습니다.");
return "redirect:/" +SETTINGS+PROFILE;
}
@GetMapping(PASSWORD)
public String updatePasswordForm(@CurrentAccount Account account, Model model) {
model.addAttribute(account);
model.addAttribute(new PasswordForm());
returnSETTINGS+PASSWORD;
}
@PostMapping(PASSWORD)
public String updatePassword(@CurrentAccount Account account, @Valid PasswordForm passwordForm, Errors errors,
Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
returnSETTINGS+PASSWORD;
}
accountService.updatePassword(account, passwordForm.getNewPassword());
attributes.addFlashAttribute("message", "패스워드를 변경했습니다.");
return "redirect:/" +SETTINGS+PASSWORD;
}
@GetMapping(NOTIFICATIONS)
public String updateNotificationsForm(@CurrentAccount Account account, Model model) {
model.addAttribute(account);
model.addAttribute(modelMapper.map(account, Notifications.class));
returnSETTINGS+NOTIFICATIONS;
}
@PostMapping(NOTIFICATIONS)
public String updateNotifications(@CurrentAccount Account account, @Valid Notifications notifications, Errors errors,
Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
returnSETTINGS+NOTIFICATIONS;
}
accountService.updateNotifications(account, notifications);
attributes.addFlashAttribute("message", "알림 설정을 변경했습니다.");
return "redirect:/" +SETTINGS+NOTIFICATIONS;
}
@GetMapping(TAGS)
public String updateTags(@CurrentAccount Account account, Model model) throws JsonProcessingException {
model.addAttribute(account);
Set<Tag> tags = accountService.getTags(account);
model.addAttribute("tags", tags.stream().map(Tag::getTitle).collect(Collectors.toList()));
List<String> allTags = tagRepository.findAll().stream().map(Tag::getTitle).collect(Collectors.toList());
model.addAttribute("whitelist", objectMapper.writeValueAsString(allTags));
returnSETTINGS+TAGS;
}
@PostMapping(TAGS+ "/add")
@ResponseBody
public ResponseEntity addTag(@CurrentAccount Account account, @RequestBody TagForm tagForm) {
Tag tag = tagService.findOrCreateNew(tagForm.getTagTitle());
accountService.addTag(account, tag);
return ResponseEntity.ok().build();
}
@PostMapping(TAGS+ "/remove")
@ResponseBody
public ResponseEntity removeTag(@CurrentAccount Account account, @RequestBody TagForm tagForm) {
String title = tagForm.getTagTitle();
Tag tag = tagRepository.findByTitle(title);
if (tag == null) {
return ResponseEntity.badRequest().build();
}
accountService.removeTag(account, tag);
return ResponseEntity.ok().build();
}
@GetMapping(ZONES)
public String updateZonesForm(@CurrentAccount Account account, Model model) throws JsonProcessingException {
model.addAttribute(account);
Set<Zone> zones = accountService.getZones(account);
model.addAttribute("zones", zones.stream().map(Zone::toString).collect(Collectors.toList()));
List<String> allZones = zoneRepository.findAll().stream().map(Zone::toString).collect(Collectors.toList());
model.addAttribute("whitelist", objectMapper.writeValueAsString(allZones));
returnSETTINGS+ZONES;
}
@PostMapping(ZONES+ "/add")
@ResponseBody
public ResponseEntity addZone(@CurrentAccount Account account, @RequestBody ZoneForm zoneForm) {
Zone zone = zoneRepository.findByCityAndProvince(zoneForm.getCityName(), zoneForm.getProvinceName());
if (zone == null) {
return ResponseEntity.badRequest().build();
}
accountService.addZone(account, zone);
return ResponseEntity.ok().build();
}
@PostMapping(ZONES+ "/remove")
@ResponseBody
public ResponseEntity removeZone(@CurrentAccount Account account, @RequestBody ZoneForm zoneForm) {
Zone zone = zoneRepository.findByCityAndProvince(zoneForm.getCityName(), zoneForm.getProvinceName());
if (zone == null) {
return ResponseEntity.badRequest().build();
}
accountService.removeZone(account, zone);
return ResponseEntity.ok().build();
}
@GetMapping(ACCOUNT)
public String updateAccountForm(@CurrentAccount Account account, Model model) {
model.addAttribute(account);
model.addAttribute(modelMapper.map(account, NicknameForm.class));
returnSETTINGS+ACCOUNT;
}
@PostMapping(ACCOUNT)
public String updateAccount(@CurrentAccount Account account, @Valid NicknameForm nicknameForm, Errors errors,
Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
returnSETTINGS+ACCOUNT;
}
accountService.updateNickname(account, nicknameForm.getNickname());
attributes.addFlashAttribute("message", "닉네임을 수정했습니다.");
return "redirect:/" +SETTINGS+ACCOUNT;
}
}
<!DOCTYPE html>
<html lang="en"
xmlns:th="<http://www.thymeleaf.org>">
<head th:replace="fragments.html :: head"></head>
<body class="bg-light">
<div th:replace="fragments.html :: main-nav"></div>
<div class="container">
<div class="row mt-5 justify-content-center">
<div class="col-2">
<div th:replace="fragments.html :: settings-menu(currentMenu='password')"></div>
</div>
<div class="col-8">
<div th:if="${message}" class="alert alert-info alert-dismissible fade show mt-3" role="alert">
<span th:text="${message}">메시지</span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="row">
<h2 class="col-sm-12" >패스워드 변경</h2>
</div>
<div class="row mt-3">
<form class="needs-validation col-12" action="#"
th:action="@{/settings/password}" th:object="${passwordForm}" method="post" novalidate>
<div class="form-group">
<label for="newPassword">새 패스워드</label>
<input id="newPassword" type="password" th:field="*{newPassword}" class="form-control"
aria-describedby="newPasswordHelp" required min="8" max="50">
<small id="newPasswordHelp" class="form-text text-muted">
새 패스워드를 입력하세요.
</small>
<small class="invalid-feedback">패스워드를 입력하세요.</small>
<small class="form-text text-danger" th:if="${#fields.hasErrors('newPassword')}" th:errors="*{newPassword}">New Password Error</small>
</div>
<div class="form-group">
<label for="newPasswordConfirm">새 패스워드 확인</label>
<input id="newPasswordConfirm" type="password" th:field="*{newPasswordConfirm}" class="form-control"
aria-describedby="newPasswordConfirmHelp" required min="8" max="50">
<small id="newPasswordConfirmHelp" class="form-text text-muted">
새 패스워드를 다시 한번 입력하세요.
</small>
<small class="invalid-feedback">새 패스워드를 다시 입력하세요.</small>
<small class="form-text text-danger" th:if="${#fields.hasErrors('newPasswordConfirm')}" th:errors="*{newPasswordConfirm}">New Password Confirm Error</small>
</div>
<div class="form-group">
<button class="btn btn-outline-primary" type="submit" aria-describedby="submitHelp">패스워드 변경하기</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script th:replace="fragments.html :: form-validation"></script>
</body>
</html>
\account\AccountService.java
package com.studyolle.modules.account;
import com.studyolle.modules.account.form.SignUpForm;
import com.studyolle.infra.config.AppProperties;
import com.studyolle.modules.tag.Tag;
import com.studyolle.modules.zone.Zone;
import com.studyolle.infra.mail.EmailMessage;
import com.studyolle.infra.mail.EmailService;
import com.studyolle.modules.account.form.Notifications;
import com.studyolle.modules.account.form.Profile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import javax.validation.Valid;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Slf4j
@Service
/*예외시 롤백역활*/
@Transactional
@RequiredArgsConstructor
public class AccountService implements UserDetailsService {
private final AccountRepository accountRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
private final ModelMapper modelMapper;
private final TemplateEngine templateEngine;
private final AppProperties appProperties;
public Account processNewAccount(SignUpForm signUpForm) {
Account newAccount = saveNewAccount(signUpForm);
sendSignUpConfirmEmail(newAccount);
return newAccount;
}
private Account saveNewAccount(@Valid SignUpForm signUpForm) {
signUpForm.setPassword(passwordEncoder.encode(signUpForm.getPassword()));
Account account = modelMapper.map(signUpForm, Account.class);
account.generateEmailCheckToken();
return accountRepository.save(account);
}
public void sendSignUpConfirmEmail(Account newAccount) {
Context context = new Context();
context.setVariable("link", "/check-email-token?token=" + newAccount.getEmailCheckToken() +
"&email=" + newAccount.getEmail());
context.setVariable("nickname", newAccount.getNickname());
context.setVariable("linkName", "이메일 인증하기");
context.setVariable("message", "스터디올래 서비스를 사용하려면 링크를 클릭하세요.");
context.setVariable("host", appProperties.getHost());
String message = templateEngine.process("mail/simple-link", context);
EmailMessage emailMessage = EmailMessage.builder()
.to(newAccount.getEmail())
.subject("스터디올래, 회원 가입 인증")
.message(message)
.build();
//emailService.sendEmail(emailMessage);
}
public void login(Account account) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
new UserAccount(account),
account.getPassword(),
List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token);
}
@Transactional(readOnly = true)
@Override
public UserDetails loadUserByUsername(String emailOrNickname) throws UsernameNotFoundException {
Account account = accountRepository.findByEmail(emailOrNickname);
if (account == null) {
account = accountRepository.findByNickname(emailOrNickname);
}
if (account == null) {
throw new UsernameNotFoundException(emailOrNickname);
}
return new UserAccount(account);
}
public void completeSignUp(Account account) {
account.completeSignUp();
login(account);
}
public void updateProfile(Account account, Profile profile) {
modelMapper.map(profile, account);
accountRepository.save(account);
}
public void updatePassword(Account account, String newPassword) {
account.setPassword(passwordEncoder.encode(newPassword));
accountRepository.save(account);
}
public void updateNotifications(Account account, Notifications notifications) {
modelMapper.map(notifications, account);
accountRepository.save(account);
}
public void updateNickname(Account account, String nickname) {
account.setNickname(nickname);
accountRepository.save(account);
login(account);
}
public void sendLoginLink(Account account) {
Context context = new Context();
context.setVariable("link", "/login-by-email?token=" + account.getEmailCheckToken() +
"&email=" + account.getEmail());
context.setVariable("nickname", account.getNickname());
context.setVariable("linkName", "스터디올래 로그인하기");
context.setVariable("message", "로그인 하려면 아래 링크를 클릭하세요.");
context.setVariable("host", appProperties.getHost());
String message = templateEngine.process("mail/simple-link", context);
/*빌더를 쓰는 이유 변수가 많아지면 코드의 가독성이 좋아진다.*/
EmailMessage emailMessage = EmailMessage.builder()
.to(account.getEmail())
.subject("스터디올래, 로그인 링크")
.message(message)
.build();
emailService.sendEmail(emailMessage);
}
public void addTag(Account account, Tag tag) {
Optional<Account> byId = accountRepository.findById(account.getId());
byId.ifPresent(a -> a.getTags().add(tag));
}
public Set<Tag> getTags(Account account) {
Optional<Account> byId = accountRepository.findById(account.getId());
return byId.orElseThrow().getTags();
}
public void removeTag(Account account, Tag tag) {
Optional<Account> byId = accountRepository.findById(account.getId());
byId.ifPresent(a -> a.getTags().remove(tag));
}
public Set<Zone> getZones(Account account) {
Optional<Account> byId = accountRepository.findById(account.getId());
return byId.orElseThrow().getZones();
}
public void addZone(Account account, Zone zone) {
Optional<Account> byId = accountRepository.findById(account.getId());
byId.ifPresent(a -> a.getZones().add(zone));
}
public void removeZone(Account account, Zone zone) {
Optional<Account> byId = accountRepository.findById(account.getId());
byId.ifPresent(a -> a.getZones().remove(zone));
}
public Account getAccount(String nickname) {
Account account = accountRepository.findByNickname(nickname);
if (account == null) {
throw new IllegalArgumentException(nickname + "에 해당하는 사용자가 없습니다.");
}
return account;
}
}