From 5f17c2fb731dd1c1d352848353cd077c214b778f Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 1 Oct 2022 08:23:35 -0500 Subject: [PATCH] Auth Email Rework (#1567) * Hooked up Send to for Series and volumes and fixed a bug where Email Service errors weren't propagating to the UI layer. When performing actions on series detail, don't disable the button anymore. * Added send to action to volumes * Fixed a bug where .kavitaignore wasn't being applied at library root level * Added a notification for when a device is being sent a file. * Added a check in forgot password for users that do not have an email set or aren't confirmed. * Added a new api for change email and moved change password directly into new Account tab (styling and logic needs testing) * Save approx scroll position like with jump key, but on normal click of card. * Implemented the ability to change your email address or set one. This requires a 2 step process using a confirmation token. This needs polishing and css. * Removed an unused directive from codebase * Fixed up some typos on publicly * Updated query for Pending Invites to also check if the user account has not logged in at least once. * Cleaned up the css for validate email change * Hooked in an indicator to tell user that a user has an unconfirmed email * Cleaned up code smells --- API/Controllers/AccountController.cs | 172 +++++++++++++++--- API/Controllers/DeviceController.cs | 19 +- API/DTOs/Account/ConfirmEmailUpdateDto.cs | 11 ++ API/DTOs/Account/UpdateEmailDto.cs | 6 + API/DTOs/Account/UpdateEmailResponse.cs | 14 ++ API/DTOs/Account/UpdateUserDto.cs | 5 - API/DTOs/Device/SendToDeviceDto.cs | 6 +- API/DTOs/RegisterDto.cs | 4 +- API/Data/Repositories/UserRepository.cs | 12 +- API/Extensions/IdentityServiceExtensions.cs | 2 +- API/Services/DeviceService.cs | 8 +- API/Services/EmailService.cs | 30 ++- .../Tasks/Scanner/ParseScannedFiles.cs | 5 +- API/SignalR/MessageFactory.cs | 17 ++ .../_models/email/update-email-response.ts | 10 + UI/Web/src/app/_models/user.ts | 1 + UI/Web/src/app/_services/account.service.ts | 13 ++ .../app/_services/action-factory.service.ts | 18 ++ UI/Web/src/app/_services/action.service.ts | 13 +- UI/Web/src/app/_services/device.service.ts | 4 +- .../src/app/_services/message-hub.service.ts | 25 ++- .../invite-user/invite-user.component.html | 4 - .../manage-email-settings.component.html | 2 +- .../src/app/all-series/all-series.module.ts | 2 - .../card-detail-drawer.component.ts | 4 +- .../card-detail-layout.component.html | 4 +- .../card-detail-layout.component.ts | 14 +- .../confirm-email-change.component.html | 18 ++ .../confirm-email-change.component.scss | 7 + .../confirm-email-change.component.ts | 55 ++++++ .../confirm-email/confirm-email.component.ts | 12 +- .../register/register.component.html | 2 +- .../register/register.component.ts | 2 +- .../app/registration/registration.module.ts | 4 +- .../registration.router.module.ts | 5 + .../series-detail.component.html | 4 +- .../series-detail/series-detail.component.ts | 72 +++----- .../shared-side-nav-cards.module.ts | 1 - UI/Web/src/app/shared/shared.module.ts | 8 +- .../app/shared/show-if-scrollbar.directive.ts | 23 --- .../change-email/change-email.component.html | 66 +++++++ .../change-email/change-email.component.scss | 5 + .../change-email/change-email.component.ts | 86 +++++++++ .../change-password.component.html | 73 ++++++++ .../change-password.component.scss | 0 .../change-password.component.ts | 82 +++++++++ .../user-preferences.component.html | 66 +------ .../user-preferences.component.ts | 70 +++---- .../app/user-settings/user-settings.module.ts | 4 + 49 files changed, 816 insertions(+), 274 deletions(-) create mode 100644 API/DTOs/Account/ConfirmEmailUpdateDto.cs create mode 100644 API/DTOs/Account/UpdateEmailDto.cs create mode 100644 API/DTOs/Account/UpdateEmailResponse.cs create mode 100644 UI/Web/src/app/_models/email/update-email-response.ts create mode 100644 UI/Web/src/app/registration/confirm-email-change/confirm-email-change.component.html create mode 100644 UI/Web/src/app/registration/confirm-email-change/confirm-email-change.component.scss create mode 100644 UI/Web/src/app/registration/confirm-email-change/confirm-email-change.component.ts delete mode 100644 UI/Web/src/app/shared/show-if-scrollbar.directive.ts create mode 100644 UI/Web/src/app/user-settings/change-email/change-email.component.html create mode 100644 UI/Web/src/app/user-settings/change-email/change-email.component.scss create mode 100644 UI/Web/src/app/user-settings/change-email/change-email.component.ts create mode 100644 UI/Web/src/app/user-settings/change-password/change-password.component.html create mode 100644 UI/Web/src/app/user-settings/change-password/change-password.component.scss create mode 100644 UI/Web/src/app/user-settings/change-password/change-password.component.ts diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 2aee4b401..660f962e6 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -19,6 +19,7 @@ using API.Services; using API.SignalR; using AutoMapper; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -188,15 +189,6 @@ public class AccountController : BaseApiController if (user == null) return Unauthorized("Invalid username"); - // Check if the user has an email, if not, inform them so they can migrate - var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password); - if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword) - { - _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName); - return Unauthorized( - "You are missing an email on your account. Please wait while we migrate your account."); - } - var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, true); @@ -256,6 +248,7 @@ public class AccountController : BaseApiController [HttpGet("roles")] public ActionResult> GetRoles() { + // TODO: This should be moved to ServerController return typeof(PolicyConstants) .GetFields(BindingFlags.Public | BindingFlags.Static) .Where(f => f.FieldType == typeof(string)) @@ -285,6 +278,86 @@ public class AccountController : BaseApiController } + + /// + /// Initiates the flow to update a user's email address. The email address is not changed in this API. A confirmation link is sent/dumped which will + /// validate the email. It must be confirmed for the email to update. + /// + /// + /// Returns just if the email was sent or server isn't reachable + [HttpPost("update/email")] + public async Task UpdateEmail(UpdateEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized("You do not have permission"); + + if (dto == null || string.IsNullOrEmpty(dto.Email)) return BadRequest("Invalid payload"); + + // Validate no other users exist with this email + if (user.Email.Equals(dto.Email)) return Ok("Nothing to do"); + + // Check if email is used by another user + var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (existingUserEmail != null) + { + return BadRequest("You cannot share emails across multiple accounts"); + } + + // All validations complete, generate a new token and email it to the user at the new address. Confirm email link will update the email + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) + { + _logger.LogError("There was an issue generating a token for the email"); + return BadRequest("There was an issue creating a confirmation email token. See logs."); + } + + user.EmailConfirmed = false; + user.ConfirmationToken = token; + await _userManager.UpdateAsync(user); + + // Send a confirmation email + try + { + var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email-update", dto.Email); + _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var accessible = await _emailService.CheckIfAccessible(host); + if (accessible) + { + try + { + // Email the old address of the update change + await _emailService.SendEmailChangeEmail(new ConfirmationEmailDto() + { + EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email, + InstallId = BuildInfo.Version.ToString(), + InvitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName, + ServerConfirmationLink = emailLink + }); + } + catch (Exception) + { + /* Swallow exception */ + } + } + + return Ok(new InviteUserResponse + { + EmailLink = string.Empty, + EmailSent = accessible + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error during invite user flow, unable to send an email"); + } + + + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); + + return Ok(); + } + /// /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. /// @@ -310,14 +383,6 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Update(user); } - if (!user.Email.Equals(dto.Email)) - { - // Validate username change - var errors = await _accountService.ValidateEmail(dto.Email); - if (errors.Any()) return BadRequest("Email already registered"); - // NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it - } - // Update roles var existingRoles = await _userManager.GetRolesAsync(user); var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -404,18 +469,22 @@ public class AccountController : BaseApiController public async Task> InviteUser(InviteUserDto dto) { var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (adminUser == null) return Unauthorized("You need to login"); + if (adminUser == null) return Unauthorized("You are not permitted"); + _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); // Check if there is an existing invite - dto.Email = dto.Email.Trim(); - var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); - if (emailValidationErrors.Any()) + if (!string.IsNullOrEmpty(dto.Email)) { - var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (await _userManager.IsEmailConfirmedAsync(invitedUser)) - return BadRequest($"User is already registered as {invitedUser.UserName}"); - return BadRequest("User is already invited under this email and has yet to accepted invite."); + dto.Email = dto.Email.Trim(); + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } } // Create a new user @@ -575,6 +644,44 @@ public class AccountController : BaseApiController }; } + /// + /// Final step in email update change. Given a confirmation token and the email, this will finish the email change. + /// + /// This will force connected clients to re-authenticate + /// + /// + [AllowAnonymous] + [HttpPost("confirm-email-update")] + public async Task ConfirmEmailUpdate(ConfirmEmailUpdateDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByConfirmationToken(dto.Token); + if (user == null) + { + return BadRequest("Invalid Email Token"); + } + + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + + + _logger.LogInformation("User is updating email from {OldEmail} to {NewEmail}", user.Email, dto.Email); + var result = await _userManager.SetEmailAsync(user, dto.Email); + if (!result.Succeeded) + { + _logger.LogError("Unable to update email for users: {Errors}", result.Errors.Select(e => e.Description)); + return BadRequest("Unable to update email for user. Check logs"); + } + user.ConfirmationToken = null; + await _unitOfWork.CommitAsync(); + + + // For the user's connected devices to pull the new information in + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, + MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); + + // Perform Login code + return Ok(); + } + [AllowAnonymous] [HttpPost("confirm-password-reset")] public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) @@ -619,15 +726,15 @@ public class AccountController : BaseApiController } var roles = await _userManager.GetRolesAsync(user); - - if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) return Unauthorized("You are not permitted to this operation."); + if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) + return BadRequest("You do not have an email on account or it has not been confirmed"); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - _logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token); var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); if (await _emailService.CheckIfAccessible(host)) { @@ -643,6 +750,15 @@ public class AccountController : BaseApiController return Ok("Your server is not accessible. The Link to reset your password is in the logs."); } + [HttpGet("email-confirmed")] + public async Task> IsEmailConfirmed() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + return Ok(user.EmailConfirmed); + } + [AllowAnonymous] [HttpPost("confirm-migration-email")] public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index 0bcdce0dd..3d67d2d7f 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -1,11 +1,15 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.Device; using API.Extensions; using API.Services; +using API.SignalR; +using ExCSS; using Kavita.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -20,12 +24,14 @@ public class DeviceController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IDeviceService _deviceService; private readonly IEmailService _emailService; + private readonly IEventHub _eventHub; - public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService) + public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService, IEventHub eventHub) { _unitOfWork = unitOfWork; _deviceService = deviceService; _emailService = emailService; + _eventHub = eventHub; } @@ -76,26 +82,33 @@ public class DeviceController : BaseApiController [HttpPost("send-to")] public async Task SendToDevice(SendToDeviceDto dto) { - if (dto.ChapterId < 0) return BadRequest("ChapterId must be greater than 0"); + if (dto.ChapterIds.Any(i => i < 0)) return BadRequest("ChapterIds must be greater than 0"); if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0"); if (await _emailService.IsDefaultEmailService()) return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own."); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId); try { - var success = await _deviceService.SendTo(dto.ChapterId, dto.DeviceId); + var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId); if (success) return Ok(); } catch (KavitaException ex) { return BadRequest(ex.Message); } + finally + { + await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId); + } return BadRequest("There was an error sending the file to the device"); } + } diff --git a/API/DTOs/Account/ConfirmEmailUpdateDto.cs b/API/DTOs/Account/ConfirmEmailUpdateDto.cs new file mode 100644 index 000000000..63d31340a --- /dev/null +++ b/API/DTOs/Account/ConfirmEmailUpdateDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class ConfirmEmailUpdateDto +{ + [Required] + public string Email { get; set; } + [Required] + public string Token { get; set; } +} diff --git a/API/DTOs/Account/UpdateEmailDto.cs b/API/DTOs/Account/UpdateEmailDto.cs new file mode 100644 index 000000000..9b92095d8 --- /dev/null +++ b/API/DTOs/Account/UpdateEmailDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.Account; + +public class UpdateEmailDto +{ + public string Email { get; set; } +} diff --git a/API/DTOs/Account/UpdateEmailResponse.cs b/API/DTOs/Account/UpdateEmailResponse.cs new file mode 100644 index 000000000..4f9b816c1 --- /dev/null +++ b/API/DTOs/Account/UpdateEmailResponse.cs @@ -0,0 +1,14 @@ +namespace API.DTOs.Account; + +public class UpdateEmailResponse +{ + /// + /// Did the user not have an existing email + /// + /// This informs the user to check the new email address + public bool HadNoExistingEmail { get; set; } + /// + /// Was an email sent (ie is this server accessible) + /// + public bool EmailSent { get; set; } +} diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index f3afb98a5..fad147507 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -6,11 +6,6 @@ public record UpdateUserDto { public int UserId { get; set; } public string Username { get; set; } - /// - /// This field will not result in any change to the User model. Changing email is not supported. - /// - public string Email { get; set; } - /// /// List of Roles to assign to user. If admin not present, Pleb will be applied. /// If admin present, all libraries will be granted access and will ignore those from DTO. /// diff --git a/API/DTOs/Device/SendToDeviceDto.cs b/API/DTOs/Device/SendToDeviceDto.cs index 20d8cf311..411f20ea0 100644 --- a/API/DTOs/Device/SendToDeviceDto.cs +++ b/API/DTOs/Device/SendToDeviceDto.cs @@ -1,7 +1,9 @@ -namespace API.DTOs.Device; +using System.Collections.Generic; + +namespace API.DTOs.Device; public class SendToDeviceDto { public int DeviceId { get; set; } - public int ChapterId { get; set; } + public IReadOnlyList ChapterIds { get; set; } } diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 95fdc70c1..4e542f1c0 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -6,7 +6,9 @@ public class RegisterDto { [Required] public string Username { get; init; } - [Required] + /// + /// An email to register with. Optional. Provides Forgot Password functionality + /// public string Email { get; init; } [Required] [StringLength(32, MinimumLength = 6)] diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index bfd739917..44a01066a 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -62,6 +62,7 @@ public interface IUserRepository Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task> GetAllUsersAsync(AppUserIncludes includeFlags); + Task GetUserByConfirmationToken(string token); } public class UserRepository : IUserRepository @@ -268,6 +269,11 @@ public class UserRepository : IUserRepository return await query.ToListAsync(); } + public async Task GetUserByConfirmationToken(string token) + { + return await _context.AppUser.SingleOrDefaultAsync(u => u.ConfirmationToken.Equals(token)); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -403,10 +409,14 @@ public class UserRepository : IUserRepository .ToListAsync(); } + /// + /// Returns a list of users that are considered Pending by invite. This means email is unconfirmed and they have never logged in + /// + /// public async Task> GetPendingMemberDtosAsync() { return await _context.Users - .Where(u => !u.EmailConfirmed) + .Where(u => !u.EmailConfirmed && u.LastActive == DateTime.MinValue) .Include(x => x.Libraries) .Include(r => r.UserRoles) .ThenInclude(r => r.Role) diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 6c32f2238..6e958638a 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -32,7 +32,7 @@ public static class IdentityServiceExtensions opt.Password.RequireNonAlphanumeric = false; opt.Password.RequiredLength = 6; - opt.SignIn.RequireConfirmedEmail = true; + opt.SignIn.RequireConfirmedEmail = false; opt.Lockout.AllowedForNewUsers = true; opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); diff --git a/API/Services/DeviceService.cs b/API/Services/DeviceService.cs index 4d5973058..ca846381a 100644 --- a/API/Services/DeviceService.cs +++ b/API/Services/DeviceService.cs @@ -7,6 +7,7 @@ using API.DTOs.Device; using API.DTOs.Email; using API.Entities; using API.Entities.Enums; +using API.SignalR; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -17,7 +18,7 @@ public interface IDeviceService Task Create(CreateDeviceDto dto, AppUser userWithDevices); Task Update(UpdateDeviceDto dto, AppUser userWithDevices); Task Delete(AppUser userWithDevices, int deviceId); - Task SendTo(int chapterId, int deviceId); + Task SendTo(IReadOnlyList chapterIds, int deviceId); } public class DeviceService : IDeviceService @@ -102,9 +103,9 @@ public class DeviceService : IDeviceService return false; } - public async Task SendTo(int chapterId, int deviceId) + public async Task SendTo(IReadOnlyList chapterIds, int deviceId) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChaptersAsync(chapterIds); if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf))) throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported"); @@ -118,6 +119,7 @@ public class DeviceService : IDeviceService DestinationEmail = device.EmailAddress, FilePaths = files.Select(m => m.FilePath) }); + return success; } } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index a9a78e0ea..32823c178 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net; -using System.Net.Http; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; @@ -14,7 +12,6 @@ using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; namespace API.Services; @@ -27,6 +24,7 @@ public interface IEmailService Task SendFilesToEmail(SendToDto data); Task TestConnectivity(string emailUrl); Task IsDefaultEmailService(); + Task SendEmailChangeEmail(ConfirmationEmailDto data); } public class EmailService : IEmailService @@ -84,6 +82,16 @@ public class EmailService : IEmailService .Equals(DefaultApiUrl); } + public async Task SendEmailChangeEmail(ConfirmationEmailDto data) + { + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + var success = await SendEmailWithPost(emailLink + "/api/account/email-change", data); + if (!success) + { + _logger.LogError("There was a critical error sending Confirmation email"); + } + } + public async Task SendConfirmationEmail(ConfirmationEmailDto data) { var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; @@ -172,18 +180,20 @@ public class EmailService : IEmailService if (response.StatusCode != StatusCodes.Status200OK) { - return false; + var errorMessage = await response.GetStringAsync(); + throw new KavitaException(errorMessage); } } - catch (Exception) + catch (FlurlHttpException ex) { + _logger.LogError(ex, "There was an exception when interacting with Email Service"); return false; } return true; } - private async Task SendEmailWithFiles(string url, IEnumerable filePaths, string destEmail, int timeoutSecs = 30) + private async Task SendEmailWithFiles(string url, IEnumerable filePaths, string destEmail, int timeoutSecs = 300) { try { @@ -193,7 +203,8 @@ public class EmailService : IEmailService .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("x-kavita-installId", settings.InstallId) - .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) + .WithTimeout(timeoutSecs) + .AllowHttpStatus("4xx") .PostMultipartAsync(mp => { mp.AddString("email", destEmail); @@ -208,10 +219,11 @@ public class EmailService : IEmailService if (response.StatusCode != StatusCodes.Status200OK) { - return false; + var errorMessage = await response.GetStringAsync(); + throw new KavitaException(errorMessage); } } - catch (Exception ex) + catch (FlurlHttpException ex) { _logger.LogError(ex, "There was an exception when sending Email for SendTo"); return false; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 98e533353..03b4ca9d5 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -82,7 +82,8 @@ public class ParseScannedFiles { // This is used in library scan, so we should check first for a ignore file and use that here as well var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile); - var directories = _directoryService.GetDirectories(folderPath, _directoryService.CreateMatcherFromFile(potentialIgnoreFile)).ToList(); + var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile); + var directories = _directoryService.GetDirectories(folderPath, matcher).ToList(); foreach (var directory in directories) { @@ -94,7 +95,7 @@ public class ParseScannedFiles else { // For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication - await folderAction(_directoryService.ScanFiles(directory), directory); + await folderAction(_directoryService.ScanFiles(directory, matcher), directory); } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 1784b17ad..a702396d3 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -112,6 +112,10 @@ public static class MessageFactory /// A generic message that can occur in background processing to inform user, but no direct action is needed /// public const string Info = "Info"; + /// + /// When files are being emailed to a device + /// + public const string SendingToDevice = "SendingToDevice"; public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) @@ -249,6 +253,19 @@ public static class MessageFactory }; } + public static SignalRMessage SendingToDeviceEvent(string subtitle, string eventType) + { + return new SignalRMessage + { + Name = SendingToDevice, + Title = "Sending files to Device", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new { } + }; + } + public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId) { return new SignalRMessage diff --git a/UI/Web/src/app/_models/email/update-email-response.ts b/UI/Web/src/app/_models/email/update-email-response.ts new file mode 100644 index 000000000..eaaf64580 --- /dev/null +++ b/UI/Web/src/app/_models/email/update-email-response.ts @@ -0,0 +1,10 @@ +export interface UpdateEmailResponse { + /** + * Did the user not have an existing email + */ + hadNoExistingEmail: boolean; + /** + * Was an email sent (ie is this server accessible) + */ + emailSent: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 626e56a5f..ba26206dd 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -8,4 +8,5 @@ export interface User { roles: string[]; preferences: Preferences; apiKey: string; + email: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 600c616ad..71a8b1489 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -11,6 +11,7 @@ import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; import { DeviceService } from './device.service'; +import { UpdateEmailResponse } from '../_models/email/update-email-response'; @Injectable({ providedIn: 'root' @@ -132,6 +133,10 @@ export class AccountService implements OnDestroy { ); } + isEmailConfirmed() { + return this.httpClient.get(this.baseUrl + 'account/email-confirmed'); + } + migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'}); } @@ -152,6 +157,10 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/confirm-email', model); } + confirmEmailUpdate(model: {email: string, token: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-email-update', model); + } + /** * Given a user id, returns a full url for setting up the user account * @param userId @@ -181,6 +190,10 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/update', model); } + updateEmail(email: string) { + return this.httpClient.post(this.baseUrl + 'account/update/email', {email}); + } + /** * This will get latest preferences for a user and cache them into user store * @returns diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index e1ee9fc87..3ff5ee74c 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -383,6 +383,24 @@ export class ActionFactoryService { } ] }, + { + action: Action.Submenu, + title: 'Send To', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.SendTo, + title: '', + callback: this.dummyCallback, + requiresAdmin: false, + dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { + return {'title': d.name, 'data': d}; + }), shareReplay())), + children: [] + } + ], + }, { action: Action.Download, title: 'Download', diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index ba905174c..489c5c30d 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -8,10 +8,12 @@ import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-t import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; import { ConfirmService } from '../shared/confirm.service'; import { Chapter } from '../_models/chapter'; +import { Device } from '../_models/device/device'; import { Library } from '../_models/library'; import { ReadingList } from '../_models/reading-list'; import { Series } from '../_models/series'; import { Volume } from '../_models/volume'; +import { DeviceService } from './device.service'; import { LibraryService } from './library.service'; import { MemberService } from './member.service'; import { ReaderService } from './reader.service'; @@ -39,7 +41,7 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, - private confirmService: ConfirmService, private memberService: MemberService) { } + private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { } ngOnDestroy() { this.onDestroy.next(); @@ -552,6 +554,15 @@ export class ActionService implements OnDestroy { }); } + sendToDevice(chapterIds: Array, device: Device, callback?: VoidActionCallback) { + this.deviceSerivce.sendTo(chapterIds, device.id).subscribe(() => { + this.toastr.success('File emailed to ' + device.name); + if (callback) { + callback(); + } + }); + } + private async promptIfForce(extraContent: string = '') { // Prompt user if we should do a force or not const config = this.confirmService.defaultConfirm; diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index c33ae2b8c..7996d50bc 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -40,8 +40,8 @@ export class DeviceService { })); } - sendTo(chapterId: number, deviceId: number) { - return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'}); + sendTo(chapterIds: Array, deviceId: number) { + return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'}); } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 05fa96e94..d454f3866 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -51,31 +51,35 @@ export enum EVENTS { /** * A subtype of NotificationProgress that represents a file being processed for cover image extraction */ - CoverUpdateProgress = 'CoverUpdateProgress', + CoverUpdateProgress = 'CoverUpdateProgress', /** * A library is created or removed from the instance */ - LibraryModified = 'LibraryModified', + LibraryModified = 'LibraryModified', /** * A user updates an entities read progress */ - UserProgressUpdate = 'UserProgressUpdate', + UserProgressUpdate = 'UserProgressUpdate', /** * A user updates account or preferences */ - UserUpdate = 'UserUpdate', + UserUpdate = 'UserUpdate', /** * When bulk bookmarks are being converted */ - ConvertBookmarksProgress = 'ConvertBookmarksProgress', + ConvertBookmarksProgress = 'ConvertBookmarksProgress', /** * When files are being scanned to calculate word count */ - WordCountAnalyzerProgress = 'WordCountAnalyzerProgress', + WordCountAnalyzerProgress = 'WordCountAnalyzerProgress', /** * When the user needs to be informed, but it's not a big deal */ - Info = 'Info', + Info = 'Info', + /** + * A user is sending files to their device + */ + SendingToDevice = 'SendingToDevice', } export interface Message { @@ -261,6 +265,13 @@ export class MessageHubService { payload: resp.body }); }); + + this.hubConnection.on(EVENTS.SendingToDevice, resp => { + this.messagesSource.next({ + event: EVENTS.SendingToDevice, + payload: resp.body + }); + }); } stopHubConnection() { diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 620af2e67..a4042ff89 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -45,10 +45,6 @@