Removed add account functionality in favor of invite user.
This commit is contained in:
parent
9b8a450234
commit
cff73d9803
|
@ -2,6 +2,8 @@ package nl.andrewl.coyotecredit.ctl;
|
|||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.AddAccountPayload;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.EditExchangePayload;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.InviteUserPayload;
|
||||
import nl.andrewl.coyotecredit.model.User;
|
||||
import nl.andrewl.coyotecredit.service.ExchangeService;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
|
@ -9,6 +11,8 @@ import org.springframework.stereotype.Controller;
|
|||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = "/exchanges")
|
||||
@RequiredArgsConstructor
|
||||
|
@ -33,16 +37,28 @@ public class ExchangeController {
|
|||
return "exchange/accounts";
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{exchangeId}/addAccount")
|
||||
public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
|
||||
@GetMapping(path = "/{exchangeId}/inviteUser")
|
||||
public String getInviteUserPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
|
||||
exchangeService.ensureAdminAccount(exchangeId, user);
|
||||
return "exchange/addAccount";
|
||||
return "exchange/invite_user";
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{exchangeId}/addAccount")
|
||||
public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) {
|
||||
long accountId = exchangeService.addAccount(exchangeId, user, payload);
|
||||
return "redirect:/accounts/" + accountId;
|
||||
@PostMapping(path = "/{exchangeId}/inviteUser")
|
||||
public String postInviteUser(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute InviteUserPayload payload) {
|
||||
exchangeService.inviteUser(exchangeId, user, payload);
|
||||
return "redirect:/exchanges/" + exchangeId;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{exchangeId}/acceptInvite/{inviteId}")
|
||||
public String postAcceptInvite(@PathVariable long exchangeId, @PathVariable long inviteId, @AuthenticationPrincipal User user) {
|
||||
exchangeService.acceptInvite(exchangeId, inviteId, user);
|
||||
return "redirect:/exchanges/" + exchangeId;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{exchangeId}/rejectInvite/{inviteId}")
|
||||
public String postRejectInvite(@PathVariable long exchangeId, @PathVariable long inviteId, @AuthenticationPrincipal User user) {
|
||||
exchangeService.rejectInvite(exchangeId, inviteId, user);
|
||||
return "redirect:/users/" + user.getId();
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{exchangeId}/removeAccount/{accountId}")
|
||||
|
@ -56,4 +72,16 @@ public class ExchangeController {
|
|||
exchangeService.removeAccount(exchangeId, accountId, user);
|
||||
return "redirect:/exchanges/" + exchangeId + "/accounts";
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{exchangeId}/edit")
|
||||
public String getEditPage(Model model, @PathVariable long exchangeId, @AuthenticationPrincipal User user) {
|
||||
model.addAttribute("exchange", exchangeService.getData(exchangeId, user));
|
||||
return "exchange/edit";
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{exchangeId}/edit")
|
||||
public String postEdit(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @Valid @ModelAttribute EditExchangePayload payload) {
|
||||
exchangeService.edit(exchangeId, payload, user);
|
||||
return "redirect:/exchanges/" + exchangeId;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,11 @@ package nl.andrewl.coyotecredit.ctl;
|
|||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
|
||||
import nl.andrewl.coyotecredit.dao.ExchangeInvitationRepository;
|
||||
import nl.andrewl.coyotecredit.service.UserService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
@ -13,14 +16,26 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||
@RequiredArgsConstructor
|
||||
public class PublicPageController {
|
||||
private final UserService userService;
|
||||
private final ExchangeInvitationRepository invitationRepository;
|
||||
|
||||
@GetMapping(path = "/login")
|
||||
public String getLoginPage() {
|
||||
return "public/login";
|
||||
}
|
||||
|
||||
@GetMapping(path = "/register")
|
||||
public String getRegisterPage() {
|
||||
@GetMapping(path = "/register") @Transactional(readOnly = true)
|
||||
public String getRegisterPage(
|
||||
Model model,
|
||||
@RequestParam(name = "inviteCode", required = false) String inviteCode
|
||||
) {
|
||||
if (inviteCode != null && !inviteCode.isBlank()) {
|
||||
invitationRepository.findByCode(inviteCode).ifPresent(invite -> {
|
||||
model.addAttribute("inviteCode", inviteCode);
|
||||
model.addAttribute("inviteUserEmail", invite.getUserEmail());
|
||||
model.addAttribute("inviteSenderName", invite.getSender().getName());
|
||||
model.addAttribute("inviteExchangeName", invite.getExchange().getName());
|
||||
});
|
||||
}
|
||||
return "public/register";
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record AddAccountPayload(
|
||||
String name,
|
||||
String email,
|
||||
String username,
|
||||
String password
|
||||
) {}
|
|
@ -0,0 +1,20 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.PositiveOrZero;
|
||||
|
||||
public record EditExchangePayload(
|
||||
@NotNull @NotBlank
|
||||
String name,
|
||||
String description,
|
||||
boolean publiclyAccessible,
|
||||
@PositiveOrZero
|
||||
long primaryTradeableId,
|
||||
|
||||
// Colors
|
||||
String primaryBackgroundColor,
|
||||
String secondaryBackgroundColor,
|
||||
String primaryForegroundColor,
|
||||
String secondaryForegroundColor
|
||||
) {}
|
|
@ -8,7 +8,13 @@ import java.util.List;
|
|||
public record FullExchangeData (
|
||||
long id,
|
||||
String name,
|
||||
String description,
|
||||
boolean publiclyAccessible,
|
||||
TradeableData primaryTradeable,
|
||||
String primaryBackgroundColor,
|
||||
String secondaryBackgroundColor,
|
||||
String primaryForegroundColor,
|
||||
String secondaryForegroundColor,
|
||||
List<TradeableData> supportedTradeables,
|
||||
String totalMarketValue,
|
||||
int accountCount,
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
public record InvitationData(
|
||||
long id,
|
||||
long exchangeId,
|
||||
String exchangeName
|
||||
) {}
|
|
@ -0,0 +1,10 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public record InviteUserPayload(
|
||||
@NotBlank @NotNull
|
||||
String email
|
||||
) {
|
||||
}
|
|
@ -3,5 +3,8 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
public record RegisterPayload (
|
||||
String username,
|
||||
String email,
|
||||
String password
|
||||
String password,
|
||||
// Invite data, this may be null.
|
||||
String inviteCode,
|
||||
String accountName
|
||||
) {}
|
||||
|
|
|
@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.ctl.dto;
|
|||
|
||||
public record SimpleAccountData (
|
||||
long id,
|
||||
long userId,
|
||||
String number,
|
||||
String name,
|
||||
boolean admin,
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package nl.andrewl.coyotecredit.ctl.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record UserData (
|
||||
long id,
|
||||
String username,
|
||||
String email
|
||||
String email,
|
||||
List<InvitationData> exchangeInvitations
|
||||
) {}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package nl.andrewl.coyotecredit.dao;
|
||||
|
||||
import nl.andrewl.coyotecredit.model.ExchangeInvitation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface ExchangeInvitationRepository extends JpaRepository<ExchangeInvitation, Long> {
|
||||
Optional<ExchangeInvitation> findByCode(String code);
|
||||
|
||||
List<ExchangeInvitation> findAllByUserEmail(String email);
|
||||
}
|
|
@ -10,4 +10,6 @@ import java.util.Optional;
|
|||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package nl.andrewl.coyotecredit.model;
|
|||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.util.HashSet;
|
||||
|
@ -23,15 +24,34 @@ public class Exchange {
|
|||
/**
|
||||
* The name for this exchange.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
@Column(nullable = false) @Setter
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* The primary tradeable that's used by this exchange.
|
||||
*/
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY) @Setter
|
||||
private Tradeable primaryTradeable;
|
||||
|
||||
/**
|
||||
* A user-provided description for the exchange.
|
||||
*/
|
||||
@Column(length = 1024) @Setter
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false) @Setter
|
||||
private boolean publiclyAccessible;
|
||||
|
||||
// Colors:
|
||||
@Column @Setter
|
||||
private String primaryBackgroundColor;
|
||||
@Column @Setter
|
||||
private String secondaryBackgroundColor;
|
||||
@Column @Setter
|
||||
private String primaryForegroundColor;
|
||||
@Column @Setter
|
||||
private String secondaryForegroundColor;
|
||||
|
||||
/**
|
||||
* The set of tradeables that this exchange allows users to interact with.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package nl.andrewl.coyotecredit.model;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/**
|
||||
* Represents an invitation which an admin can send to users (or any email
|
||||
* address) to invite them to participate in an exchange. If no user with the
|
||||
* given email address exists, then a registration code will be sent to the
|
||||
* email address, which when present at the /register page, will give context
|
||||
* to allow the user to be automatically added to the exchange.
|
||||
*/
|
||||
@Entity
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@Getter
|
||||
public class ExchangeInvitation {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
private Exchange exchange;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
private Account sender;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String userEmail;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
public ExchangeInvitation(Exchange exchange, Account sender, String userEmail, String code, LocalDateTime expiresAt) {
|
||||
this.exchange = exchange;
|
||||
this.sender = sender;
|
||||
this.userEmail = userEmail;
|
||||
this.code = code;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
}
|
|
@ -5,21 +5,24 @@ import nl.andrewl.coyotecredit.ctl.dto.*;
|
|||
import nl.andrewl.coyotecredit.dao.*;
|
||||
import nl.andrewl.coyotecredit.model.*;
|
||||
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
|
||||
import nl.andrewl.coyotecredit.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.mail.MessagingException;
|
||||
import javax.mail.internet.MimeMessage;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.DecimalFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
@ -29,8 +32,13 @@ public class ExchangeService {
|
|||
private final TransactionRepository transactionRepository;
|
||||
private final TradeableRepository tradeableRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ExchangeInvitationRepository invitationRepository;
|
||||
private final JavaMailSender mailSender;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${coyote-credit.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FullExchangeData getData(long exchangeId, User user) {
|
||||
Exchange e = exchangeRepository.findById(exchangeId)
|
||||
|
@ -46,7 +54,13 @@ public class ExchangeService {
|
|||
return new FullExchangeData(
|
||||
e.getId(),
|
||||
e.getName(),
|
||||
e.getDescription(),
|
||||
e.isPubliclyAccessible(),
|
||||
new TradeableData(e.getPrimaryTradeable()),
|
||||
e.getPrimaryBackgroundColor(),
|
||||
e.getSecondaryBackgroundColor(),
|
||||
e.getPrimaryForegroundColor(),
|
||||
e.getSecondaryForegroundColor(),
|
||||
e.getAllTradeables().stream()
|
||||
.map(TradeableData::new)
|
||||
.sorted(Comparator.comparing(TradeableData::symbol))
|
||||
|
@ -72,6 +86,7 @@ public class ExchangeService {
|
|||
.sorted(Comparator.comparing(Account::getName))
|
||||
.map(a -> new SimpleAccountData(
|
||||
a.getId(),
|
||||
a.getUser().getId(),
|
||||
a.getNumber(),
|
||||
a.getName(),
|
||||
a.isAdmin(),
|
||||
|
@ -203,9 +218,125 @@ public class ExchangeService {
|
|||
return accountRepository.findAllByUser(user).stream()
|
||||
.map(a -> new ExchangeAccountData(
|
||||
new ExchangeData(a.getExchange().getId(), a.getExchange().getName(), a.getExchange().getPrimaryTradeable().getSymbol()),
|
||||
new SimpleAccountData(a.getId(), a.getNumber(), a.getName(), a.isAdmin(), TradeableData.DECIMAL_FORMAT.format(a.getTotalBalance()))
|
||||
new SimpleAccountData(
|
||||
a.getId(),
|
||||
user.getId(),
|
||||
a.getNumber(),
|
||||
a.getName(),
|
||||
a.isAdmin(),
|
||||
TradeableData.DECIMAL_FORMAT.format(a.getTotalBalance())
|
||||
)
|
||||
))
|
||||
.sorted(Comparator.comparing(d -> d.exchange().name()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void edit(long exchangeId, EditExchangePayload payload, User user) {
|
||||
Exchange e = exchangeRepository.findById(exchangeId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
Account account = accountRepository.findByUserAndExchange(user, e)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
if (!account.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
e.setName(payload.name());
|
||||
e.setDescription(payload.description());
|
||||
Tradeable primaryTradeable = tradeableRepository.findById(payload.primaryTradeableId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown primary tradeable currency."));
|
||||
if (!e.getAllTradeables().contains(primaryTradeable)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This exchange doesn't support " + primaryTradeable.getSymbol() + ".");
|
||||
}
|
||||
if (primaryTradeable.getType().equals(TradeableType.STOCK)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid primary tradeable currency. Stocks are not permitted.");
|
||||
}
|
||||
e.setPrimaryTradeable(primaryTradeable);
|
||||
e.setPubliclyAccessible(payload.publiclyAccessible());
|
||||
|
||||
e.setPrimaryBackgroundColor(payload.primaryBackgroundColor());
|
||||
e.setSecondaryBackgroundColor(payload.secondaryBackgroundColor());
|
||||
e.setPrimaryForegroundColor(payload.primaryForegroundColor());
|
||||
e.setSecondaryForegroundColor(payload.secondaryForegroundColor());
|
||||
exchangeRepository.save(e);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void inviteUser(long exchangeId, User user, InviteUserPayload payload) {
|
||||
Exchange exchange = exchangeRepository.findById(exchangeId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
Account account = accountRepository.findByUserAndExchange(user, exchange)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
if (!account.isAdmin()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusDays(7);
|
||||
ExchangeInvitation invitation = invitationRepository.save(
|
||||
new ExchangeInvitation(exchange, account, payload.email(), StringUtils.random(64), expiresAt));
|
||||
Optional<User> invitedUser = userRepository.findByEmail(payload.email());
|
||||
if (invitedUser.isEmpty()) {
|
||||
try {
|
||||
sendInvitationEmail(invitation);
|
||||
} catch (MessagingException e) {
|
||||
e.printStackTrace();
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not send invitation email.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void acceptInvite(long exchangeId, long inviteId, User user) {
|
||||
Exchange exchange = exchangeRepository.findById(exchangeId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
ExchangeInvitation invite = invitationRepository.findById(inviteId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for someone else.");
|
||||
}
|
||||
if (!invite.getExchange().getId().equals(exchangeId)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for a different exchange.");
|
||||
}
|
||||
// If the user already has an account, silently delete the invite.
|
||||
if (accountRepository.existsByUserAndExchange(user, exchange)) {
|
||||
invitationRepository.delete(invite);
|
||||
}
|
||||
if (invite.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is expired.");
|
||||
}
|
||||
// Create the account.
|
||||
Account account = accountRepository.save(new Account(AccountNumberUtils.generate(), user, user.getUsername(), exchange));
|
||||
invitationRepository.delete(invite);
|
||||
for (var t : exchange.getAllTradeables()) {
|
||||
account.getBalances().add(new Balance(account, t, BigDecimal.ZERO));
|
||||
}
|
||||
accountRepository.save(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void rejectInvite(long exchangeId, long inviteId, User user) {
|
||||
ExchangeInvitation invite = invitationRepository.findById(inviteId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for someone else.");
|
||||
}
|
||||
if (!invite.getExchange().getId().equals(exchangeId)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invite is for a different exchange.");
|
||||
}
|
||||
invitationRepository.delete(invite);
|
||||
}
|
||||
|
||||
private void sendInvitationEmail(ExchangeInvitation invitation) throws MessagingException {
|
||||
MimeMessage msg = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(msg);
|
||||
helper.setFrom("Coyote Credit <noreply@coyote-credit.com>");
|
||||
helper.setTo(invitation.getUserEmail());
|
||||
helper.setSubject("Exchange Invitation");
|
||||
String url = baseUrl + "/register?inviteCode=" + invitation.getCode();
|
||||
helper.setText(String.format(
|
||||
"""
|
||||
<p>You have been invited by %s to join %s on Coyote Credit.
|
||||
Click the link below to register an account.</p>
|
||||
<a href="%s">%s</a>
|
||||
""",
|
||||
invitation.getSender().getName(),
|
||||
invitation.getExchange().getName(),
|
||||
url, url
|
||||
), true);
|
||||
mailSender.send(msg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
package nl.andrewl.coyotecredit.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.InvitationData;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
|
||||
import nl.andrewl.coyotecredit.ctl.dto.UserData;
|
||||
import nl.andrewl.coyotecredit.dao.AccountRepository;
|
||||
import nl.andrewl.coyotecredit.dao.ExchangeInvitationRepository;
|
||||
import nl.andrewl.coyotecredit.dao.UserActivationTokenRepository;
|
||||
import nl.andrewl.coyotecredit.dao.UserRepository;
|
||||
import nl.andrewl.coyotecredit.model.Account;
|
||||
import nl.andrewl.coyotecredit.model.Balance;
|
||||
import nl.andrewl.coyotecredit.model.User;
|
||||
import nl.andrewl.coyotecredit.model.UserActivationToken;
|
||||
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
|
||||
import nl.andrewl.coyotecredit.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
@ -20,14 +26,20 @@ import org.springframework.web.server.ResponseStatusException;
|
|||
|
||||
import javax.mail.MessagingException;
|
||||
import javax.mail.internet.MimeMessage;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final AccountRepository accountRepository;
|
||||
private final UserActivationTokenRepository activationTokenRepository;
|
||||
private final ExchangeInvitationRepository exchangeInvitationRepository;
|
||||
private final JavaMailSender mailSender;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
|
@ -42,7 +54,13 @@ public class UserService {
|
|||
} else {
|
||||
user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
return new UserData(user.getId(), user.getUsername(), user.getEmail());
|
||||
List<InvitationData> exchangeInvitations = new ArrayList<>();
|
||||
for (var invitation : exchangeInvitationRepository.findAllByUserEmail(user.getEmail())) {
|
||||
if (invitation.isExpired()) continue;
|
||||
exchangeInvitations.add(new InvitationData(invitation.getId(), invitation.getExchange().getId(), invitation.getExchange().getName()));
|
||||
}
|
||||
exchangeInvitations.sort(Comparator.comparing(InvitationData::id));
|
||||
return new UserData(user.getId(), user.getUsername(), user.getEmail(), exchangeInvitations);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
@ -52,6 +70,26 @@ public class UserService {
|
|||
}
|
||||
User user = new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email());
|
||||
user = userRepository.save(user);
|
||||
if (payload.inviteCode() != null) {
|
||||
var invite = exchangeInvitationRepository.findByCode(payload.inviteCode())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid invitation code."));
|
||||
if (!invite.getUserEmail().equalsIgnoreCase(user.getEmail())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invitation code is for somebody else.");
|
||||
}
|
||||
if (invite.isExpired()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This invitation code is expired.");
|
||||
}
|
||||
exchangeInvitationRepository.delete(invite);
|
||||
Account account = new Account(AccountNumberUtils.generate(), user, payload.accountName(), invite.getExchange());
|
||||
account = accountRepository.save(account);
|
||||
for (var tradeable : invite.getExchange().getAllTradeables()) {
|
||||
account.getBalances().add(new Balance(account, tradeable, BigDecimal.ZERO));
|
||||
}
|
||||
account = accountRepository.save(account);
|
||||
user.getAccounts().add(account);
|
||||
user = userRepository.save(user);
|
||||
}
|
||||
|
||||
String token = StringUtils.random(64);
|
||||
LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24);
|
||||
UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
th:replace="~{layout/basic_page :: layout (title='Edit Balances', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Edit Balances</h1>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
th:replace="~{layout/basic_page :: layout (title='Transfer', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1 class="display-4">Transfer</h1>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Home', content=~{::#content})}"
|
||||
th:replace="~{layout/basic_page :: layout (title='Error', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<div class="row justify-content-center">
|
||||
|
|
|
@ -19,14 +19,18 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="account : ${accounts}">
|
||||
<td><a th:href="@{/accounts/{id}(id=${account.id()})}" th:text="${account.number()}"></a></td>
|
||||
<td><a class="colored-link" th:href="@{/accounts/{id}(id=${account.id()})}" th:text="${account.number()}"></a></td>
|
||||
<td th:text="${account.name()}"></td>
|
||||
<td th:text="${account.admin()}"></td>
|
||||
<td class="monospace" th:text="${account.totalBalance()}"></td>
|
||||
<td><a th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}">Remove</a></td>
|
||||
<td><a
|
||||
class="colored-link"
|
||||
th:href="@{/exchanges/{eId}/removeAccount/{aId}(eId=${exchangeId}, aId=${account.id()})}"
|
||||
th:if="${account.userId() != #authentication.getPrincipal().getId()}"
|
||||
>Remove</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}">Add Account</a>
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/inviteUser(eId=${exchangeId})}">Invite User</a>
|
||||
</div>
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Add Account</h1>
|
||||
<p>
|
||||
Use this page to add an account to the exchange.
|
||||
</p>
|
||||
|
||||
<form th:href="@{/exchanges/{eId}/addAccount(eId=${exchangeId})}" th:method="post">
|
||||
<div class="mb-3">
|
||||
<label for="nameInput" class="form-label">Name</label>
|
||||
<input id="nameInput" type="text" class="form-control" name="name" required/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="usernameInput" class="form-label">Username</label>
|
||||
<input id="usernameInput" type="text" class="form-control" name="username" required/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="emailInput" class="form-label">Email</label>
|
||||
<input id="emailInput" type="email" class="form-control" name="email" required/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="passwordInput" class="form-label">Password</label>
|
||||
<input id="passwordInput" type="password" class="form-control" name="password" required/>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,85 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Edit Exchange', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1 class="display-4">Edit Exchange Settings</h1>
|
||||
|
||||
<form th:action="@{/exchanges/{eId}/edit(eId=${exchange.id()})}" th:method="post">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="nameInput" class="form-label">Name</label>
|
||||
<input type="text" name="name" id="nameInput" class="form-control" th:value="${exchange.name()}"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="descriptionInput" class="form-label">Description</label>
|
||||
<textarea id="descriptionInput" name="description" class="form-control" rows="3" maxlength="1024" th:text="${exchange.description()}"></textarea>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="publiclyAccessible" id="publiclyAccessibleCheck" th:checked="${exchange.publiclyAccessible()}"/>
|
||||
<input type="hidden" value="on" name="_publiclyAccessible"/>
|
||||
<label class="form-check-label" for="publiclyAccessibleCheck">Publicly Accessible</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="primaryTradeableIdSelect" class="form-label">Primary Currency</label>
|
||||
<select name="primaryTradeableId" id="primaryTradeableIdSelect" class="form-select">
|
||||
<option
|
||||
th:each="tb : ${exchange.supportedTradeables()}"
|
||||
th:if="${tb.type().equals('FIAT') || tb.type().equals('CRYPTO')}"
|
||||
th:text="${tb.symbol() + ' ' + tb.name()}"
|
||||
th:value="${tb.id()}"
|
||||
th:selected="${tb.id() == exchange.primaryTradeable().id()}"
|
||||
></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="primaryBackgroundColorInput" class="form-label">Primary Background Color</label>
|
||||
<input
|
||||
name="primaryBackgroundColor"
|
||||
id="primaryBackgroundColorInput"
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
th:value="${exchange.primaryBackgroundColor()}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="secondaryBackgroundColorInput" class="form-label">Secondary Background Color</label>
|
||||
<input
|
||||
name="secondaryBackgroundColor"
|
||||
id="secondaryBackgroundColorInput"
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
th:value="${exchange.secondaryBackgroundColor()}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="primaryForegroundColorInput" class="form-label">Primary Foreground Color</label>
|
||||
<input
|
||||
name="primaryForegroundColor"
|
||||
id="primaryForegroundColorInput"
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
th:value="${exchange.primaryForegroundColor()}"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="secondaryForegroundColorInput" class="form-label">Secondary Foreground Color</label>
|
||||
<input
|
||||
name="secondaryForegroundColor"
|
||||
id="secondaryForegroundColorInput"
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
th:value="${exchange.secondaryForegroundColor()}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
|
@ -6,6 +6,7 @@
|
|||
>
|
||||
<div id="content" class="container">
|
||||
<h1 class="display-4" th:text="${exchange.name()}"></h1>
|
||||
<p class="lead" th:if="${exchange.description() != null && !exchange.description().isBlank()}" th:text="${exchange.description()}"></p>
|
||||
|
||||
<div class="card text-white bg-dark mb-3">
|
||||
<div class="card-body">
|
||||
|
@ -22,10 +23,13 @@
|
|||
<dt class="col-sm-6">Number of Accounts</dt>
|
||||
<dd class="col-sm-6" th:text="${exchange.accountCount()}"></dd>
|
||||
</dl>
|
||||
<a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Tradeable Assets</h3>
|
||||
<div class="card text-white bg-dark mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Tradeable Assets</h5>
|
||||
<table class="table table-dark">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -44,9 +48,14 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a>
|
||||
<a class="btn btn-primary" th:if="${exchange.accountAdmin()}" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">All Accounts</a>
|
||||
<div class="card text-white bg-dark mb-3" th:if="${exchange.accountAdmin()}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Administrator Tools</h5>
|
||||
<a class="btn btn-primary" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">View All Accounts</a>
|
||||
<a class="btn btn-secondary" th:href="@{/exchanges/{eId}/edit(eId=${exchange.id()})}">Edit Exchange Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Invite User', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1 class="display-4">Invite User</h1>
|
||||
<p class="lead">
|
||||
Invite someone to join your exchange. If they've already got an account,
|
||||
their account will receive an invitation, or if not, they'll receive an
|
||||
email so they can create an account.
|
||||
</p>
|
||||
|
||||
<form th:href="@{/exchanges/{eId}/inviteUser(eId=${exchangeId})}" th:method="post">
|
||||
<div class="mb-3">
|
||||
<label for="emailInput" class="form-label">Email</label>
|
||||
<input id="emailInput" name="email" type="email" class="form-control" required/>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" th:value="${_csrf.getToken()}"/>
|
||||
<button type="submit" class="btn btn-primary">Invite</button>
|
||||
</form>
|
||||
</div>
|
|
@ -2,7 +2,7 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Add Account', content=~{::#content})}"
|
||||
th:replace="~{layout/basic_page :: layout (title='Remove Account', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>Remove Account</h1>
|
||||
|
|
|
@ -16,7 +16,15 @@
|
|||
|
||||
<div class="mb-3">
|
||||
<label for="emailInput" class="form-label">Email</label>
|
||||
<input name="email" id="emailInput" class="form-control" type="email" required/>
|
||||
<input
|
||||
name="email"
|
||||
id="emailInput"
|
||||
class="form-control"
|
||||
type="email"
|
||||
required
|
||||
th:readonly="${inviteUserEmail != null}"
|
||||
th:value="${inviteUserEmail == null ? '' : inviteUserEmail}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
@ -24,7 +32,21 @@
|
|||
<input name="password" id="passwordInput" class="form-control" type="password" required/>
|
||||
</div>
|
||||
|
||||
<input type="submit" class="btn btn-primary" value="Register">
|
||||
<div class="mb-3" th:if="${inviteCode != null}">
|
||||
<label for="accountNameInput" class="form-label">Account Name</label>
|
||||
<small class="text-muted">
|
||||
You have been invited by <span th:text="${inviteSenderName}"></span> to join <span th:text="${inviteExchangeName}"></span>.
|
||||
</small>
|
||||
<input name="accountName" id="accountNameInput" class="form-control" type="text" required/>
|
||||
<input name="inviteCode" type="hidden" th:value="${inviteCode}"/>
|
||||
</div>
|
||||
|
||||
<input type="submit" class="btn btn-primary mb-3" value="Register">
|
||||
|
||||
<p class="alert alert-dark">
|
||||
After registering, you will receive an email with a verification link. Please
|
||||
click that link to verify your account before attempting to log in.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html
|
||||
lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{layout/basic_page :: layout (title='Account', content=~{::#content})}"
|
||||
th:replace="~{layout/basic_page :: layout (title='Tradeable', content=~{::#content})}"
|
||||
>
|
||||
<div id="content" class="container">
|
||||
<h1>
|
||||
|
|
|
@ -19,4 +19,22 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card text-white bg-dark mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Exchange Invitations</h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item list-group-item-dark" th:each="invite : ${user.exchangeInvitations()}">
|
||||
<span th:text="${invite.exchangeName()}"></span>
|
||||
|
||||
<form th:action="@{/exchanges/{eId}/rejectInvite/{iId}(eId=${invite.exchangeId()}, iId=${invite.id()})}" method="post" class="float-end">
|
||||
<button type="submit" class="btn btn-danger">Reject</button>
|
||||
</form>
|
||||
<form th:action="@{/exchanges/{eId}/acceptInvite/{iId}(eId=${invite.exchangeId()}, iId=${invite.id()})}" method="post" class="float-end">
|
||||
<button type="submit" class="btn btn-success">Accept</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Reference in New Issue