Removed add account functionality in favor of invite user.

This commit is contained in:
Andrew Lalis 2022-02-16 10:34:58 +01:00
parent 9b8a450234
commit cff73d9803
28 changed files with 563 additions and 92 deletions

View File

@ -2,6 +2,8 @@ package nl.andrewl.coyotecredit.ctl;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.AddAccountPayload; 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.model.User;
import nl.andrewl.coyotecredit.service.ExchangeService; import nl.andrewl.coyotecredit.service.ExchangeService;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -9,6 +11,8 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Controller @Controller
@RequestMapping(path = "/exchanges") @RequestMapping(path = "/exchanges")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -33,16 +37,28 @@ public class ExchangeController {
return "exchange/accounts"; return "exchange/accounts";
} }
@GetMapping(path = "/{exchangeId}/addAccount") @GetMapping(path = "/{exchangeId}/inviteUser")
public String getAddAccountPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) { public String getInviteUserPage(@PathVariable long exchangeId, @AuthenticationPrincipal User user) {
exchangeService.ensureAdminAccount(exchangeId, user); exchangeService.ensureAdminAccount(exchangeId, user);
return "exchange/addAccount"; return "exchange/invite_user";
} }
@PostMapping(path = "/{exchangeId}/addAccount") @PostMapping(path = "/{exchangeId}/inviteUser")
public String postAddAcount(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute AddAccountPayload payload) { public String postInviteUser(@PathVariable long exchangeId, @AuthenticationPrincipal User user, @ModelAttribute InviteUserPayload payload) {
long accountId = exchangeService.addAccount(exchangeId, user, payload); exchangeService.inviteUser(exchangeId, user, payload);
return "redirect:/accounts/" + accountId; 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}") @GetMapping(path = "/{exchangeId}/removeAccount/{accountId}")
@ -56,4 +72,16 @@ public class ExchangeController {
exchangeService.removeAccount(exchangeId, accountId, user); exchangeService.removeAccount(exchangeId, accountId, user);
return "redirect:/exchanges/" + exchangeId + "/accounts"; 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;
}
} }

View File

@ -2,8 +2,11 @@ package nl.andrewl.coyotecredit.ctl;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload; import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
import nl.andrewl.coyotecredit.dao.ExchangeInvitationRepository;
import nl.andrewl.coyotecredit.service.UserService; import nl.andrewl.coyotecredit.service.UserService;
import org.springframework.stereotype.Controller; 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.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -13,14 +16,26 @@ import org.springframework.web.bind.annotation.RequestParam;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PublicPageController { public class PublicPageController {
private final UserService userService; private final UserService userService;
private final ExchangeInvitationRepository invitationRepository;
@GetMapping(path = "/login") @GetMapping(path = "/login")
public String getLoginPage() { public String getLoginPage() {
return "public/login"; return "public/login";
} }
@GetMapping(path = "/register") @GetMapping(path = "/register") @Transactional(readOnly = true)
public String getRegisterPage() { 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"; return "public/register";
} }

View File

@ -1,8 +0,0 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record AddAccountPayload(
String name,
String email,
String username,
String password
) {}

View File

@ -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
) {}

View File

@ -8,7 +8,13 @@ import java.util.List;
public record FullExchangeData ( public record FullExchangeData (
long id, long id,
String name, String name,
String description,
boolean publiclyAccessible,
TradeableData primaryTradeable, TradeableData primaryTradeable,
String primaryBackgroundColor,
String secondaryBackgroundColor,
String primaryForegroundColor,
String secondaryForegroundColor,
List<TradeableData> supportedTradeables, List<TradeableData> supportedTradeables,
String totalMarketValue, String totalMarketValue,
int accountCount, int accountCount,

View File

@ -0,0 +1,7 @@
package nl.andrewl.coyotecredit.ctl.dto;
public record InvitationData(
long id,
long exchangeId,
String exchangeName
) {}

View File

@ -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
) {
}

View File

@ -3,5 +3,8 @@ package nl.andrewl.coyotecredit.ctl.dto;
public record RegisterPayload ( public record RegisterPayload (
String username, String username,
String email, String email,
String password String password,
// Invite data, this may be null.
String inviteCode,
String accountName
) {} ) {}

View File

@ -2,6 +2,7 @@ package nl.andrewl.coyotecredit.ctl.dto;
public record SimpleAccountData ( public record SimpleAccountData (
long id, long id,
long userId,
String number, String number,
String name, String name,
boolean admin, boolean admin,

View File

@ -1,7 +1,10 @@
package nl.andrewl.coyotecredit.ctl.dto; package nl.andrewl.coyotecredit.ctl.dto;
import java.util.List;
public record UserData ( public record UserData (
long id, long id,
String username, String username,
String email String email,
List<InvitationData> exchangeInvitations
) {} ) {}

View File

@ -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);
}

View File

@ -10,4 +10,6 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
boolean existsByUsername(String username); boolean existsByUsername(String username);
Optional<User> findByEmail(String email);
} }

View File

@ -3,6 +3,7 @@ package nl.andrewl.coyotecredit.model;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*; import javax.persistence.*;
import java.util.HashSet; import java.util.HashSet;
@ -23,15 +24,34 @@ public class Exchange {
/** /**
* The name for this exchange. * The name for this exchange.
*/ */
@Column(nullable = false) @Column(nullable = false) @Setter
private String name; private String name;
/** /**
* The primary tradeable that's used by this exchange. * 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; 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. * The set of tradeables that this exchange allows users to interact with.
*/ */

View File

@ -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));
}
}

View File

@ -5,21 +5,24 @@ import nl.andrewl.coyotecredit.ctl.dto.*;
import nl.andrewl.coyotecredit.dao.*; import nl.andrewl.coyotecredit.dao.*;
import nl.andrewl.coyotecredit.model.*; import nl.andrewl.coyotecredit.model.*;
import nl.andrewl.coyotecredit.util.AccountNumberUtils; 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.http.HttpStatus;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.text.DecimalFormat; import java.text.DecimalFormat;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Comparator; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -29,8 +32,13 @@ public class ExchangeService {
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final TradeableRepository tradeableRepository; private final TradeableRepository tradeableRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final ExchangeInvitationRepository invitationRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Value("${coyote-credit.base-url}")
private String baseUrl;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public FullExchangeData getData(long exchangeId, User user) { public FullExchangeData getData(long exchangeId, User user) {
Exchange e = exchangeRepository.findById(exchangeId) Exchange e = exchangeRepository.findById(exchangeId)
@ -46,7 +54,13 @@ public class ExchangeService {
return new FullExchangeData( return new FullExchangeData(
e.getId(), e.getId(),
e.getName(), e.getName(),
e.getDescription(),
e.isPubliclyAccessible(),
new TradeableData(e.getPrimaryTradeable()), new TradeableData(e.getPrimaryTradeable()),
e.getPrimaryBackgroundColor(),
e.getSecondaryBackgroundColor(),
e.getPrimaryForegroundColor(),
e.getSecondaryForegroundColor(),
e.getAllTradeables().stream() e.getAllTradeables().stream()
.map(TradeableData::new) .map(TradeableData::new)
.sorted(Comparator.comparing(TradeableData::symbol)) .sorted(Comparator.comparing(TradeableData::symbol))
@ -72,6 +86,7 @@ public class ExchangeService {
.sorted(Comparator.comparing(Account::getName)) .sorted(Comparator.comparing(Account::getName))
.map(a -> new SimpleAccountData( .map(a -> new SimpleAccountData(
a.getId(), a.getId(),
a.getUser().getId(),
a.getNumber(), a.getNumber(),
a.getName(), a.getName(),
a.isAdmin(), a.isAdmin(),
@ -203,9 +218,125 @@ public class ExchangeService {
return accountRepository.findAllByUser(user).stream() return accountRepository.findAllByUser(user).stream()
.map(a -> new ExchangeAccountData( .map(a -> new ExchangeAccountData(
new ExchangeData(a.getExchange().getId(), a.getExchange().getName(), a.getExchange().getPrimaryTradeable().getSymbol()), 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())) .sorted(Comparator.comparing(d -> d.exchange().name()))
.toList(); .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);
}
} }

View File

@ -1,12 +1,18 @@
package nl.andrewl.coyotecredit.service; package nl.andrewl.coyotecredit.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import nl.andrewl.coyotecredit.ctl.dto.InvitationData;
import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload; import nl.andrewl.coyotecredit.ctl.dto.RegisterPayload;
import nl.andrewl.coyotecredit.ctl.dto.UserData; 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.UserActivationTokenRepository;
import nl.andrewl.coyotecredit.dao.UserRepository; 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.User;
import nl.andrewl.coyotecredit.model.UserActivationToken; import nl.andrewl.coyotecredit.model.UserActivationToken;
import nl.andrewl.coyotecredit.util.AccountNumberUtils;
import nl.andrewl.coyotecredit.util.StringUtils; import nl.andrewl.coyotecredit.util.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -20,14 +26,20 @@ import org.springframework.web.server.ResponseStatusException;
import javax.mail.MessagingException; import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMessage;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserService { public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AccountRepository accountRepository;
private final UserActivationTokenRepository activationTokenRepository; private final UserActivationTokenRepository activationTokenRepository;
private final ExchangeInvitationRepository exchangeInvitationRepository;
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@ -42,7 +54,13 @@ public class UserService {
} else { } else {
user = userRepository.findById(userId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); 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 @Transactional
@ -52,6 +70,26 @@ public class UserService {
} }
User user = new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email()); User user = new User(payload.username(), passwordEncoder.encode(payload.password()), payload.email());
user = userRepository.save(user); 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); String token = StringUtils.random(64);
LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24); LocalDateTime expiresAt = LocalDateTime.now(ZoneOffset.UTC).plusHours(24);
UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt); UserActivationToken activationToken = new UserActivationToken(token, user, expiresAt);

View File

@ -2,7 +2,7 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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"> <div id="content" class="container">
<h1>Edit Balances</h1> <h1>Edit Balances</h1>

View File

@ -2,7 +2,7 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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"> <div id="content" class="container">
<h1 class="display-4">Transfer</h1> <h1 class="display-4">Transfer</h1>

View File

@ -2,7 +2,7 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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 id="content" class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">

View File

@ -19,14 +19,18 @@
</thead> </thead>
<tbody> <tbody>
<tr th:each="account : ${accounts}"> <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.name()}"></td>
<td th:text="${account.admin()}"></td> <td th:text="${account.admin()}"></td>
<td class="monospace" th:text="${account.totalBalance()}"></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> </tr>
</tbody> </tbody>
</table> </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> </div>

View File

@ -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>

View File

@ -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>

View File

@ -6,6 +6,7 @@
> >
<div id="content" class="container"> <div id="content" class="container">
<h1 class="display-4" th:text="${exchange.name()}"></h1> <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 text-white bg-dark mb-3">
<div class="card-body"> <div class="card-body">
@ -22,10 +23,13 @@
<dt class="col-sm-6">Number of Accounts</dt> <dt class="col-sm-6">Number of Accounts</dt>
<dd class="col-sm-6" th:text="${exchange.accountCount()}"></dd> <dd class="col-sm-6" th:text="${exchange.accountCount()}"></dd>
</dl> </dl>
<a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a>
</div> </div>
</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"> <table class="table table-dark">
<thead> <thead>
<tr> <tr>
@ -44,9 +48,14 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div>
<div> <div class="card text-white bg-dark mb-3" th:if="${exchange.accountAdmin()}">
<a class="btn btn-primary" th:href="@{/accounts/{aId}(aId=${exchange.accountId()})}">My Account</a> <div class="card-body">
<a class="btn btn-primary" th:if="${exchange.accountAdmin()}" th:href="@{/exchanges/{eId}/accounts(eId=${exchange.id()})}">All Accounts</a> <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>
</div> </div>

View File

@ -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>

View File

@ -2,7 +2,7 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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"> <div id="content" class="container">
<h1>Remove Account</h1> <h1>Remove Account</h1>

View File

@ -16,7 +16,15 @@
<div class="mb-3"> <div class="mb-3">
<label for="emailInput" class="form-label">Email</label> <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>
<div class="mb-3"> <div class="mb-3">
@ -24,7 +32,21 @@
<input name="password" id="passwordInput" class="form-control" type="password" required/> <input name="password" id="passwordInput" class="form-control" type="password" required/>
</div> </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> </form>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<html <html
lang="en" lang="en"
xmlns:th="http://www.thymeleaf.org" 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"> <div id="content" class="container">
<h1> <h1>

View File

@ -19,4 +19,22 @@
</tr> </tr>
</tbody> </tbody>
</table> </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> </div>