diff --git a/checkstyle.xml b/checkstyle.xml index 18b066a50..85f193cdf 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -146,7 +146,13 @@ - + + + + + diff --git a/frontend/css/axe_form_styles.css b/frontend/css/axe_form_styles.css new file mode 100644 index 000000000..0e132d0bd --- /dev/null +++ b/frontend/css/axe_form_styles.css @@ -0,0 +1,24 @@ +.axe-form-title { + align-self: center; +} + +.axe-form-subtitle { + padding-left: 1rem; +} +.space-after-submit-button { + width: 100%; +} + +.vertically-compact { + padding-top: 1px; + padding-bottom: 1px; +} + +.axe-fields > * { + min-width: 99%; +} + +.space-after-fields { + padding-left: 1rem; + max-width: 99%; +} diff --git a/frontend/css/common_styles.css b/frontend/css/common_styles.css index 1de36d8c9..26a712a62 100644 --- a/frontend/css/common_styles.css +++ b/frontend/css/common_styles.css @@ -209,3 +209,19 @@ vaadin-drawer-toggle { vaadin-icon[icon="vaadin:sign-in"] { padding-left: 0 !important; } + +.green { + color: green; +} + +.red { + color: red; +} + +.fit-in-window { + flex-wrap: wrap; +} + +.fit-in-window > * { + flex: 1 0 auto; +} diff --git a/frontend/css/danger_dialog.css b/frontend/css/danger_dialog.css new file mode 100644 index 000000000..954967604 --- /dev/null +++ b/frontend/css/danger_dialog.css @@ -0,0 +1,20 @@ +html { + --axe-warning-zone-color: rgb(231 235 239); +} + +html[theme="dark"] { + --axe-warning-zone-color: rgb(55 72 95); +} + +.danger-dialog { + max-width: min(501px,97%); +} + +.italic-text { + font-style: italic; +} + +.danger-dialog-warning-zone { + background: var(--axe-warning-zone-color); +} + diff --git a/frontend/css/forgot_password_page.css b/frontend/css/forgot_password_page.css new file mode 100644 index 000000000..88ea3cab4 --- /dev/null +++ b/frontend/css/forgot_password_page.css @@ -0,0 +1,3 @@ +.result-span { + padding-left: 1rem; +} diff --git a/frontend/css/login_page.css b/frontend/css/login_page.css new file mode 100644 index 000000000..b206f55bb --- /dev/null +++ b/frontend/css/login_page.css @@ -0,0 +1,4 @@ +.forgot-password-section { + text-align: center; + width: 100%; +} diff --git a/frontend/css/profile_page.css b/frontend/css/profile_page.css new file mode 100644 index 000000000..9495a5365 --- /dev/null +++ b/frontend/css/profile_page.css @@ -0,0 +1,28 @@ +.profile-title { + align-self: center; +} + +.toggle-with-prefix-postfix { + margin-right: 0.3rem; + margin-left: 0.3rem; +} + +.tab-content { + padding-left: 0!important; + padding-right: 0!important; +} + +@media (max-width: 400px) { + .fit-in-section { + width: min-content; + } +} + +.telegram-details { + display: block; + max-width: min(290px, 100%); +} + +.telegram-details div { + margin: 0.5rem; +} diff --git a/frontend/css/registration_page.css b/frontend/css/registration_page.css new file mode 100644 index 000000000..87200051b --- /dev/null +++ b/frontend/css/registration_page.css @@ -0,0 +1,8 @@ +.input { + flex-shrink: 0.3; + width: 100%; +} + +.info-button { + border-radius: 100%; +} diff --git a/src/main/java/pm/axe/Axe.java b/src/main/java/pm/axe/Axe.java index 5ead552e9..9c59c6107 100644 --- a/src/main/java/pm/axe/Axe.java +++ b/src/main/java/pm/axe/Axe.java @@ -32,6 +32,7 @@ public static class C { public static final int ONE_SECOND_IN_MILLIS = 1000; public static final String TIME_DATE_FORMAT = "dd/MM/yyyy HH:mm:ss z"; public static final String MINUS = "-"; + public static final String PLUS = "+"; } public static class Defaults { diff --git a/src/main/java/pm/axe/Endpoint.java b/src/main/java/pm/axe/Endpoint.java index eab9b026c..ea0977b5c 100644 --- a/src/main/java/pm/axe/Endpoint.java +++ b/src/main/java/pm/axe/Endpoint.java @@ -86,6 +86,12 @@ public static class UI { public static final String WELCOME_PAGE = "welcome"; public static final String REGISTRATION_FAILED_PAGE = "registrationFailed"; public static final String LOGIN_PAGE = "login"; + public static final String REGISTRATION_PAGE = "register"; + public static final String FORGOT_PASSWORD_PAGE = "forgot-password"; + public static final String TOS_PAGE = "terms-of-service"; + public static final String PROFILE_PAGE = "profile"; + public static final String CONFIRM_ACCOUNT_PAGE = "confirm-account"; + public static final String LOGIN_VERIFICATION_PAGE = "verify"; } /** diff --git a/src/main/java/pm/axe/api/user/PostUserRestController.java b/src/main/java/pm/axe/api/user/PostUserRestController.java index c5f49bd3f..50f412644 100644 --- a/src/main/java/pm/axe/api/user/PostUserRestController.java +++ b/src/main/java/pm/axe/api/user/PostUserRestController.java @@ -103,7 +103,7 @@ public ResponseEntity registerUser(final @RequestBody PostUserRequest request } } else { log.info("{} There is no user-defined Username in Request. Generating custom Username", TAG); - OperationResult usernameGenerationResult = usernameGenerator.generate(); + OperationResult usernameGenerationResult = usernameGenerator.generateRandom(); if (usernameGenerationResult.ok()) { username = usernameGenerationResult.getStringPayload(); } else { diff --git a/src/main/java/pm/axe/core/IdentGenerator.java b/src/main/java/pm/axe/core/IdentGenerator.java index 23527dea6..63733fe4f 100644 --- a/src/main/java/pm/axe/core/IdentGenerator.java +++ b/src/main/java/pm/axe/core/IdentGenerator.java @@ -34,9 +34,6 @@ public final class IdentGenerator { public static final String VALID_IDENT_PATTERN = "^[a-zA-Z0-9]([._-](?![._-])|[a-zA-Z0-9]){0," + IDENT_MAX_LENGTH_WITHOUT_FIRST_AND_LAST_CHARS + "}[a-zA-Z0-9]$"; - private static final int TOKEN_SUBSTRING_START_INDEX = 0; - private static final int TOKEN_SUBSTRING_END_INDEX = 7; - private IdentGenerator() { throw new UnsupportedOperationException("Utility class"); } @@ -68,7 +65,9 @@ public static String generateTokenIdent(final Token token) { private static String generateAccountConfirmationIdent(final Token token) { String prefix = token.getTokenType().getIdentPrefix(); - String firstPartOfToken = token.getToken().substring(TOKEN_SUBSTRING_START_INDEX, TOKEN_SUBSTRING_END_INDEX); - return String.join("", prefix, firstPartOfToken); + String randomNumber = RandomStringUtils.randomNumeric(1); + String randomChar = RandomStringUtils.randomAlphanumeric(1); + String randomNum = RandomStringUtils.randomNumeric(1); + return String.join("", prefix, randomNumber, randomChar, randomNum); } } diff --git a/src/main/java/pm/axe/db/dao/AccountDao.java b/src/main/java/pm/axe/db/dao/AccountDao.java index 4f900536c..d25408fde 100644 --- a/src/main/java/pm/axe/db/dao/AccountDao.java +++ b/src/main/java/pm/axe/db/dao/AccountDao.java @@ -21,6 +21,15 @@ public interface AccountDao extends CrudRepository { */ Optional findByUserAndType(User user, AccountType accountType); + /** + * Searching for User's Account by its {@link AccountType}. + * + * @param user account's owner + * @param accountType type of account + * @return true - if found or false - if not. + */ + boolean existsByUserAndType(User user, AccountType accountType); + /** * Lists all {@link Account}s of given {@link AccountType}. * diff --git a/src/main/java/pm/axe/db/dao/TokenDao.java b/src/main/java/pm/axe/db/dao/TokenDao.java index bead824cc..f705469f7 100644 --- a/src/main/java/pm/axe/db/dao/TokenDao.java +++ b/src/main/java/pm/axe/db/dao/TokenDao.java @@ -3,6 +3,7 @@ import lombok.NonNull; import org.springframework.scheduling.annotation.Async; import pm.axe.db.dao.base.TimeAwareCrudDao; +import pm.axe.db.models.Account; import pm.axe.db.models.Token; import pm.axe.db.models.User; import pm.axe.users.TokenType; @@ -62,6 +63,17 @@ public interface TokenDao extends TimeAwareCrudDao { */ Token findByTokenTypeAndUser(TokenType tokenType, User user); + /** + * Finds {@link TokenType#ACCOUNT_CONFIRMATION_TOKEN} by its {@link TokenType}, {@link User} and {@link Account}. + * + * @param tokenType normally {@link TokenType#ACCOUNT_CONFIRMATION_TOKEN}. + * @param user {@link Token}'s owner + * @param account {@link Account} to be confirmed + * + * @return {@link Optional} with found {@link Token} record or {@link Optional#empty()}. + */ + Optional findByTokenTypeAndUserAndConfirmationFor(TokenType tokenType, User user, Account account); + /** * Finds all {@link Token} records owned by {@link User}. * diff --git a/src/main/java/pm/axe/db/models/Account.java b/src/main/java/pm/axe/db/models/Account.java index 05455ab78..a60c8f9f6 100644 --- a/src/main/java/pm/axe/db/models/Account.java +++ b/src/main/java/pm/axe/db/models/Account.java @@ -41,6 +41,21 @@ public static Account.Builder create(final AccountType accountType) { return new Builder(accountType); } + /** + * This method updates fields iof current {@link Account} record with values from another. + * + * @param anotherAccount another non-null {@link Account} record to copy values from. + * @throws IllegalArgumentException when anotherAccount is NULL. + */ + public void copy(final Account anotherAccount) { + if (anotherAccount == null) throw new IllegalArgumentException("Another Account cannot be null"); + this.setUser(anotherAccount.getUser()); + this.setType(anotherAccount.getType()); + this.setAccountName(anotherAccount.getAccountName()); + this.setConfirmed(anotherAccount.isConfirmed()); + this.setExtraInfo(anotherAccount.getExtraInfo()); + } + private Account(final AccountType accountType, final User user) { this.type = accountType; this.user = user; diff --git a/src/main/java/pm/axe/db/models/Token.java b/src/main/java/pm/axe/db/models/Token.java index c976a4614..5a0221424 100644 --- a/src/main/java/pm/axe/db/models/Token.java +++ b/src/main/java/pm/axe/db/models/Token.java @@ -23,7 +23,7 @@ public class Token extends TimeModel { /** * Default length of Token (code). */ - private static final int CODE_TOKEN_LEN = 6; + public static final int CODE_TOKEN_LEN = 6; @Column(name = "token", nullable = false, unique = true) private String token; diff --git a/src/main/java/pm/axe/internal/HasTabInit.java b/src/main/java/pm/axe/internal/HasTabInit.java new file mode 100644 index 000000000..f568737dc --- /dev/null +++ b/src/main/java/pm/axe/internal/HasTabInit.java @@ -0,0 +1,16 @@ +package pm.axe.internal; + +import com.vaadin.flow.component.tabs.Tab; +import pm.axe.db.models.User; + +/** + * This interface determines, that {@link Tab} has {@link #tabInit(User)} method. + */ +public interface HasTabInit { + /** + * Method, that should be called at initialization phase. + * + * @param user bound {@link User}. Usually stored within current session. + */ + void tabInit(User user); +} diff --git a/src/main/java/pm/axe/mail/EmailConfirmationStatus.java b/src/main/java/pm/axe/mail/EmailConfirmationStatus.java new file mode 100644 index 000000000..73d77914e --- /dev/null +++ b/src/main/java/pm/axe/mail/EmailConfirmationStatus.java @@ -0,0 +1,19 @@ +package pm.axe.mail; + +import com.vaadin.flow.component.icon.VaadinIcon; +import lombok.Getter; + +public enum EmailConfirmationStatus { + CONFIRMED(VaadinIcon.CHECK, "Email confirmed"), + PENDING(VaadinIcon.ELLIPSIS_CIRCLE, "Validation pending..."), + FAILED(VaadinIcon.FIRE, "Validation failed"), + NONE(VaadinIcon.COG, "No status defined yet"); + + @Getter + private final VaadinIcon icon; + @Getter private final String statusString; + EmailConfirmationStatus(final VaadinIcon icon, final String statusString) { + this.icon = icon; + this.statusString = statusString; + } +} diff --git a/src/main/java/pm/axe/services/user/AccountService.java b/src/main/java/pm/axe/services/user/AccountService.java index cc71af633..9a8e89685 100644 --- a/src/main/java/pm/axe/services/user/AccountService.java +++ b/src/main/java/pm/axe/services/user/AccountService.java @@ -209,6 +209,17 @@ public Optional getAccount(final User user, final AccountType accountTy return accountDao.findByUserAndType(user, accountType); } + /** + * Checks if Account with given {@link User} and {@link AccountType} exists. + * + * @param user account's owner + * @param accountType account's type + * @return true if exists, false if not. + */ + public boolean isAccountExist(final User user, final AccountType accountType) { + return accountDao.existsByUserAndType(user, accountType); + } + /** * Searches {@link Account} by plain-text {@link Account} name and {@link AccountType}. * @@ -295,4 +306,113 @@ public OperationResult deleteAccount(final Account account) { return OperationResult.generalFail().withMessage(e.getMessage()); } } + + /** + * Updates {@link AccountType#EMAIL} {@link Account}. + * This method marks updated {@link AccountType#EMAIL} {@link Account} as unconfirmed. + * + * @param user {@link Account} owner. + * + * @param email non-empty string with new Email Address (plain value). + * + * @return {@link OperationResult#success()} with updated {@link Account} as payload, + * {@link OperationResult#malformedInput()}, when new email address not valid. + * {@link OperationResult#generalFail()} with {@link #ERR_ENCRYPTION_FAILED} message, + * when encryption failed. + */ + public OperationResult updateEmailAccount(final User user, final String email) { + if (user == null) return OperationResult.malformedInput().withMessage("User cannot be NULL"); + OperationResult validationResult = mailService.isEmailValid(email); + if (validationResult.notOk()) { + return validationResult; + } + + Optional emailAccount = getAccount(user, AccountType.EMAIL); + boolean userHasEmailAccount; + if (emailAccount.isPresent()) { + userHasEmailAccount = true; + String encryptedEmail; + OperationResult encryptEmailResult = cryptTool.encrypt(email); + if (encryptEmailResult.ok()) { + encryptedEmail = encryptEmailResult.getStringPayload(); + } else { + log.error("{} Email encryption failed. Value: {}. Error: {}", + TAG, email, encryptEmailResult.getMessage()); + return OperationResult.generalFail().withMessage(ERR_ENCRYPTION_FAILED); + } + emailAccount.get().setAccountName(encryptedEmail); + emailAccount.get().setConfirmed(false); + } else { + userHasEmailAccount = false; + } + + try { + if (userHasEmailAccount) { + accountDao.save(emailAccount.get()); + log.info("{} Updated email account for {} {}", TAG, User.class.getSimpleName(), user.getUsername()); + return OperationResult.success().addPayload(emailAccount.get()); + } else { + return createEmailAccount(user, email); + } + } catch (CannotCreateTransactionException e) { + return OperationResult.databaseDown(); + } catch (Exception e) { + log.debug("", e); + return OperationResult.generalFail().withMessage(e.getMessage()); + } + } + + /** + * This method restores values from old {@link Account}. + * + * @param oldAccount old {@link Account} to restore values from. + * @throws IllegalArgumentException when old account is NULL. + */ + public void rollbackAccount(final Account oldAccount) { + if (oldAccount == null) throw new IllegalArgumentException("old account cannot be null"); + Optional currentAccount = this.getAccount(oldAccount.getUser(), oldAccount.getType()); + try { + if (currentAccount.isPresent()) { + currentAccount.get().copy(oldAccount); + } else { + accountDao.save(oldAccount); + } + log.info("{} Account {} rolled back", TAG, oldAccount); + } catch (CannotCreateTransactionException e) { + log.error("{} failed to rollback account: Database is Down", TAG); + } catch (Exception e) { + log.error("{} failed to rollback Account {}: got exception {}", TAG, oldAccount, e.getMessage()); + } + } + + /** + * Returns current email address. + * + * @param user email's owner + * @return {@link Optional} with email address or {@link Optional#empty()}. + */ + public Optional getCurrentEmail(final User user) { + if (user == null) throw new IllegalArgumentException("User cannot be NULL"); + + Optional emailAccount = getAccount(user, AccountType.EMAIL); + if (emailAccount.isEmpty()) return Optional.empty(); + Optional plainTextEmail = decryptAccountName(emailAccount.get()); + if (plainTextEmail.isEmpty()) return Optional.empty(); + if (StringUtils.isNotBlank(plainTextEmail.get())) { + return plainTextEmail; + } else { + return Optional.empty(); + } + } + + /** + * Is current {@link User} confirmed. + * + * @param user email's owner + * @return true if email confirmed, false if email not confirmed or email doesn't exist. + */ + public boolean isCurrentEmailConfirmed(final User user) { + Optional emailAccount = getAccount(user, AccountType.EMAIL); + return emailAccount.map(Account::isConfirmed).orElse(false); + } } diff --git a/src/main/java/pm/axe/services/user/TokenService.java b/src/main/java/pm/axe/services/user/TokenService.java index 5651f1807..ada86ff0a 100644 --- a/src/main/java/pm/axe/services/user/TokenService.java +++ b/src/main/java/pm/axe/services/user/TokenService.java @@ -24,8 +24,6 @@ public class TokenService { private static final String TAG = "[" + TokenService.class.getSimpleName() + "]"; - private static final String ERR_USER_ALREADY_HAS_TOKEN = "User already has token"; - private final TokenDao tokenDao; /** @@ -36,17 +34,19 @@ public class TokenService { * @return {@link OperationResult} with created {@link Token} or {@link OperationResult} with error. */ public OperationResult createConfirmationToken(final User user, final Account account) { - boolean userAlreadyHasConfirmationToken = - tokenDao.existsByTokenTypeAndUser(TokenType.ACCOUNT_CONFIRMATION_TOKEN, user); - if (userAlreadyHasConfirmationToken) { - return OperationResult.banned().withMessage(ERR_USER_ALREADY_HAS_TOKEN); + Optional optionalToken = + tokenDao.findByTokenTypeAndUserAndConfirmationFor(TokenType.ACCOUNT_CONFIRMATION_TOKEN, user, account); + Token confirmationToken; + if (optionalToken.isPresent()) { + //use it again + confirmationToken = optionalToken.get(); + } else { + //create new + confirmationToken = Token.create(TokenType.ACCOUNT_CONFIRMATION_TOKEN).forUser(user); + confirmationToken.setConfirmationFor(account); + verifyTokenValueIsUnique(confirmationToken); } - Token confirmationToken = Token.create(TokenType.ACCOUNT_CONFIRMATION_TOKEN).forUser(user); - confirmationToken.setConfirmationFor(account); - - verifyTokenValueIsUnique(confirmationToken); - try { tokenDao.save(confirmationToken); return OperationResult.success().addPayload(confirmationToken); @@ -172,7 +172,22 @@ public Optional getToken(final String tokenString) { return token.isPresent() ? returnOnlyValidToken(token.get()) : Optional.empty(); } + /** + * Gets Token by {@link User} and {@link TokenType}. + * + * @param user {@link Token}'s owner + * @param tokenType {@link Token}'s type + * + * @return {@link Optional} with valid {@link Token} or {@link Optional#empty()} + * @throws IllegalArgumentException when user or token typer params are null + */ + public Optional getToken(final User user, final TokenType tokenType) { + if (user == null) throw new IllegalArgumentException("user cannot be null"); + if (tokenType == null) throw new IllegalArgumentException("token type cannot be null"); + Token token = tokenDao.findByTokenTypeAndUser(tokenType, user); + return token != null ? returnOnlyValidToken(token) : Optional.empty(); + } /** * Provides {@link User}'s {@link TokenType#TELEGRAM_CONFIRMATION_TOKEN} token. diff --git a/src/main/java/pm/axe/services/user/UserOperationsService.java b/src/main/java/pm/axe/services/user/UserOperationsService.java index c709da78a..3c7e1f1ce 100644 --- a/src/main/java/pm/axe/services/user/UserOperationsService.java +++ b/src/main/java/pm/axe/services/user/UserOperationsService.java @@ -108,24 +108,14 @@ public OperationResult registerUser(final RegisterUserInput input) { } //Create and send - confirmation email for Accounts with Email set. if (userAccount.getType() == AccountType.EMAIL) { - //Create Confirmation Token - OperationResult createConfirmationTokenResult = - tokenService.createConfirmationToken(createdUser, userAccount); - if (createConfirmationTokenResult.notOk()) { - log.error("{} failed to create confirmation token for {}. OpResult: {}", - TAG, createdUser.getUsername(), createConfirmationTokenResult); - return createConfirmationTokenResult; - } - Token confirmationToken = createConfirmationTokenResult.getPayload(Token.class); - rollbackTasks.push(RollbackTask.create(Token.class, confirmationToken)); - //Send it - log.info("{} Successfully created {}({}) for user '{}'", - TAG, confirmationToken.getTokenType(), confirmationToken.getToken(), createdUser.getUsername()); - OperationResult sendResult = senders.getSender(AccountType.EMAIL).send(confirmationToken, input.getEmail()); - if (sendResult.notOk()) { - log.warn("{} Unable to send created {} to {}. OpResult: {}", - TAG, confirmationToken.getTokenType(), input.getEmail(), sendResult); - log.warn("{} Requesting Rollback", TAG); + Optional confirmationToken = createConfirmationToken(userAccount); + if (confirmationToken.isPresent()) { + rollbackTasks.push(RollbackTask.create(Token.class, confirmationToken.get())); + OperationResult sendConfirmationLetterResult = + sendConfirmationLetter(confirmationToken.get(), input.getEmail(), userAccount); + if (sendConfirmationLetterResult.notOk()) return sendConfirmationLetterResult; + } else { + log.error("{} Failed to create confirmation {}", TAG, Token.class.getSimpleName()); rollbackService.rollback(rollbackTasks); } } @@ -147,6 +137,66 @@ public OperationResult registerUser(final RegisterUserInput input) { ? success : success.addPayload(TELEGRAM_TOKEN_KEY, telegramConfirmationToken.getToken()); } + /** + * Creates Confirmation {@link Token}. + * @param userAccount {@link Account} to confirm with given Token. + * + * @return {@link Optional} with {@link Token} or {@link Optional#empty()} if failed t create token. + */ + public Optional createConfirmationToken(final Account userAccount) { + if (userAccount == null) { + throw new IllegalArgumentException("User Account cannot be null"); + } + OperationResult createConfirmationTokenResult = + tokenService.createConfirmationToken(userAccount.getUser(), userAccount); + if (createConfirmationTokenResult.notOk()) { + log.error("{} failed to create confirmation token for {}. OpResult: {}", + TAG, userAccount.getUser().getUsername(), createConfirmationTokenResult); + return Optional.empty(); + } + return Optional.ofNullable(createConfirmationTokenResult.getPayload(Token.class)); + } + + /** + * Sends {@link TokenType#ACCOUNT_CONFIRMATION_TOKEN}. + * + * @param confirmationToken {@link TokenType#ACCOUNT_CONFIRMATION_TOKEN} {@link Token} + * @param email non-empty string with email + * @param userAccount {@link Account} to confirm with given Token + * + * @return {@link OperationResult#success()}, when email send, + * {@link OperationResult#malformedInput()}, when something is wrong with params, + * {@link OperationResult#generalFail()}, when failed to send letter. + */ + public OperationResult sendConfirmationLetter(final Token confirmationToken, final String email, + final Account userAccount) { + //check inputs + if (StringUtils.isBlank(email)) { + return OperationResult.malformedInput().withMessage("Email cannot be empty"); + } + if (userAccount == null) { + return OperationResult.malformedInput().withMessage("User Account cannot be null"); + } + if (userAccount.getUser() == null) { + return OperationResult.malformedInput().withMessage("Given Account is not bound to any user"); + } + + + //Send it + log.info("{} Successfully created {}({}) for user '{}'", + TAG, confirmationToken.getTokenType(), confirmationToken.getToken(), + userAccount.getUser().getUsername()); + OperationResult sendResult = senders.getSender(AccountType.EMAIL).send(confirmationToken, email); + if (sendResult.notOk()) { + log.warn("{} Unable to send created {} to {}. OpResult: {}", + TAG, confirmationToken.getTokenType(), email, sendResult); + log.warn("{} Requesting Rollback", TAG); + rollbackService.rollback(rollbackTasks); + return sendResult; + } + return OperationResult.success(); + } + /** * Deletes User and its Accounts. * @@ -246,4 +296,92 @@ public OperationResult deleteUser(final User user, final boolean force) { EventBus.getDefault().post(UserDeletedEvent.createWith(user)); return deletionResult; } + + /** + * Updates email and also resets channel. + * + * @param user email account owner + * @param email plain text email address + * @return {@link OperationResult} with update email {@link Account} or {@link OperationResult} with fail state. + */ + public OperationResult updateEmailAccount(final User user, final String email) { + OperationResult accountUpdateResult = accountService.updateEmailAccount(user, email); + if (accountUpdateResult.notOk()) { + return accountUpdateResult; + } + Account account = accountUpdateResult.getPayload(Account.class); + OperationResult resetResult = resetChannels(account); + if (resetResult.notOk()) { + return resetResult; + } + return OperationResult.success().addPayload(account); + } + + /** + * Deletes {@link Account} and resets channels in {@link UserSettings}. + * + * @param account non-empty {@link Account} to update. + * @return delete account {@link OperationResult}. + * + */ + public OperationResult deleteAccountOnly(final Account account) { + if (account == null) throw new IllegalArgumentException("Account cannot be null"); + if (account.getUser() == null) throw new IllegalStateException("Account has no owner"); + + Optional accountConfirmationToken = tokenService.getToken(account.getUser(), + TokenType.ACCOUNT_CONFIRMATION_TOKEN); + if (accountConfirmationToken.isPresent()) { + //because we need to delete it in @Sync manner + OperationResult tokenDeletionResult = tokenService.deleteToken(accountConfirmationToken.get().getToken()); + if (tokenDeletionResult.notOk()) { + return tokenDeletionResult; + } + } + + //also reset channels (tfa, reset) + OperationResult resetResult = resetChannels(account); + if (resetResult.notOk()) { + return resetResult; + } + + //and finally, delete it + return accountService.deleteAccount(account); + } + + private OperationResult resetChannels(final Account account) { + if (account == null) throw new IllegalArgumentException("account cannot be null"); + if (account.getUser() == null) throw new IllegalStateException("account has no owner"); + + Optional userSettings = userSettingsService.getUserSettings(account.getUser()); + if (userSettings.isPresent()) { + UserSettings us = userSettings.get(); + if (us.getTfaChannel() == account.getType()) { + us.setTfaChannel(getAnotherConfirmedAccount(us.getUser())); + } + if (us.getPasswordResetChannel() == account.getType()) { + us.setPasswordResetChannel(getAnotherConfirmedAccount(us.getUser())); + } + return userSettingsService.updateUserSettings(us); + } else { + return OperationResult.generalFail().withMessage("UserSettings not found for " + + account.getUser().getUsername()); + } + } + + private AccountType getAnotherConfirmedAccount(final User user) { + if (user == null) throw new IllegalArgumentException("user cannot be null"); + List confirmedAccounts = accountService.getAllAccountsLinkedWithUser(user).stream() + .filter(Account::isConfirmed).toList(); + if (confirmedAccounts.size() > 1) { + //more than one confirmed account - user should decide, which one to use. + return AccountType.LOCAL; + } else if (confirmedAccounts.size() == 1) { + //just using another confirmed account + return confirmedAccounts.get(0).getType(); + } else { + //no more confirmed accounts - reset + return AccountType.LOCAL; + } + } + } diff --git a/src/main/java/pm/axe/services/user/UserService.java b/src/main/java/pm/axe/services/user/UserService.java index 89a9f5e31..2db03583c 100644 --- a/src/main/java/pm/axe/services/user/UserService.java +++ b/src/main/java/pm/axe/services/user/UserService.java @@ -196,5 +196,33 @@ void confirmUser(final User user) { userDao.update(user); } + /** + * Updates username. + * + * @param user {@link User}, that should be updated. + * @param username non-empty string with new username. + * + * @return {@link OperationResult#malformedInput()} if username doesn't meet requirements + * or {@link OperationResult#success()} if update was successful. + */ + public OperationResult updateUsername(final User user, final String username) { + if (user == null) return OperationResult.malformedInput().withMessage("User cannot be NULL"); + OperationResult validationResult = UsernameValidator.isValid(username); + if (validationResult.notOk()) { + return validationResult; + } + user.setUsername(username); + try { + userDao.update(user); + return OperationResult.success(); + } catch (CannotCreateTransactionException e) { + return OperationResult.databaseDown(); + } catch (Exception e) { + log.error("{} Exception on updating {} record", + TAG, User.class.getSimpleName()); + log.debug("", e); + return OperationResult.generalFail(); + } + } } diff --git a/src/main/java/pm/axe/services/user/UserSettingsService.java b/src/main/java/pm/axe/services/user/UserSettingsService.java index 8a468e5b5..06e56c364 100644 --- a/src/main/java/pm/axe/services/user/UserSettingsService.java +++ b/src/main/java/pm/axe/services/user/UserSettingsService.java @@ -62,6 +62,17 @@ public void deleteUserSettings(final UserSettings settings) { userSettingsDao.delete(settings); } + /** + * Checks if user has 2FA enabled or not. + * + * @param user {@link UserSettings} owner + * @return true - if 2FA is enabled for given {@link User}, false - in other cases. + */ + public boolean isTfaEnabled(final User user) { + Optional userSettings = getUserSettings(user); + return userSettings.map(UserSettings::isTfaEnabled).orElse(false); + } + private OperationResult createOrUpdateUserSettings(final UserSettings userSettings) { try { userSettingsDao.save(userSettings); @@ -77,4 +88,6 @@ private OperationResult createOrUpdateUserSettings(final UserSettings userSettin return OperationResult.generalFail(); } } + + } diff --git a/src/main/java/pm/axe/ui/elements/AppMenu.java b/src/main/java/pm/axe/ui/elements/AppMenu.java index 67fa68ff9..3a328900b 100644 --- a/src/main/java/pm/axe/ui/elements/AppMenu.java +++ b/src/main/java/pm/axe/ui/elements/AppMenu.java @@ -11,6 +11,8 @@ import com.vaadin.flow.component.menubar.MenuBar; import com.vaadin.flow.component.menubar.MenuBarVariant; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import pm.axe.ui.pages.user.LoginPage; +import pm.axe.ui.pages.user.RegistrationPage; import pm.axe.utils.ErrorUtils; /** @@ -18,6 +20,9 @@ */ public final class AppMenu extends Composite { + private final Button loginButton = getLoginButton(); + private final Button registerButton = getRegisterButton(); + /** * Creates menu, that should be shown to all visitors. * @@ -58,7 +63,7 @@ private AppMenu(final boolean isUserMenu) { if (isUserMenu) { userMenuButtons.add(getLogoutButton()); } else { - userMenuButtons.add(getLoginButton(), getRegisterButton()); + userMenuButtons.add(loginButton, registerButton); } //buttons @@ -99,11 +104,11 @@ private void onLogoutButtonClicked(final ClickEvent