mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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
This commit is contained in:
parent
3792ac3421
commit
5f17c2fb73
@ -19,6 +19,7 @@ using API.Services;
|
|||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -188,15 +189,6 @@ public class AccountController : BaseApiController
|
|||||||
|
|
||||||
if (user == null) return Unauthorized("Invalid username");
|
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
|
var result = await _signInManager
|
||||||
.CheckPasswordSignInAsync(user, loginDto.Password, true);
|
.CheckPasswordSignInAsync(user, loginDto.Password, true);
|
||||||
|
|
||||||
@ -256,6 +248,7 @@ public class AccountController : BaseApiController
|
|||||||
[HttpGet("roles")]
|
[HttpGet("roles")]
|
||||||
public ActionResult<IList<string>> GetRoles()
|
public ActionResult<IList<string>> GetRoles()
|
||||||
{
|
{
|
||||||
|
// TODO: This should be moved to ServerController
|
||||||
return typeof(PolicyConstants)
|
return typeof(PolicyConstants)
|
||||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||||
.Where(f => f.FieldType == typeof(string))
|
.Where(f => f.FieldType == typeof(string))
|
||||||
@ -285,6 +278,86 @@ public class AccountController : BaseApiController
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns>Returns just if the email was sent or server isn't reachable</returns>
|
||||||
|
[HttpPost("update/email")]
|
||||||
|
public async Task<ActionResult> 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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
|
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -310,14 +383,6 @@ public class AccountController : BaseApiController
|
|||||||
_unitOfWork.UserRepository.Update(user);
|
_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
|
// Update roles
|
||||||
var existingRoles = await _userManager.GetRolesAsync(user);
|
var existingRoles = await _userManager.GetRolesAsync(user);
|
||||||
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
|
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
|
||||||
@ -404,18 +469,22 @@ public class AccountController : BaseApiController
|
|||||||
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
||||||
{
|
{
|
||||||
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
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);
|
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
|
||||||
|
|
||||||
// Check if there is an existing invite
|
// Check if there is an existing invite
|
||||||
dto.Email = dto.Email.Trim();
|
if (!string.IsNullOrEmpty(dto.Email))
|
||||||
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
|
|
||||||
if (emailValidationErrors.Any())
|
|
||||||
{
|
{
|
||||||
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
|
dto.Email = dto.Email.Trim();
|
||||||
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
|
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
|
||||||
return BadRequest($"User is already registered as {invitedUser.UserName}");
|
if (emailValidationErrors.Any())
|
||||||
return BadRequest("User is already invited under this email and has yet to accepted invite.");
|
{
|
||||||
|
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
|
// Create a new user
|
||||||
@ -575,6 +644,44 @@ public class AccountController : BaseApiController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Final step in email update change. Given a confirmation token and the email, this will finish the email change.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This will force connected clients to re-authenticate</remarks>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("confirm-email-update")]
|
||||||
|
public async Task<ActionResult> 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]
|
[AllowAnonymous]
|
||||||
[HttpPost("confirm-password-reset")]
|
[HttpPost("confirm-password-reset")]
|
||||||
public async Task<ActionResult<string>> ConfirmForgotPassword(ConfirmPasswordResetDto dto)
|
public async Task<ActionResult<string>> ConfirmForgotPassword(ConfirmPasswordResetDto dto)
|
||||||
@ -619,15 +726,15 @@ public class AccountController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
|
|
||||||
if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
|
if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
|
||||||
return Unauthorized("You are not permitted to this operation.");
|
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 token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email);
|
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]: 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();
|
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||||
if (await _emailService.CheckIfAccessible(host))
|
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.");
|
return Ok("Your server is not accessible. The Link to reset your password is in the logs.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("email-confirmed")]
|
||||||
|
public async Task<ActionResult<bool>> IsEmailConfirmed()
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(user.EmailConfirmed);
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("confirm-migration-email")]
|
[HttpPost("confirm-migration-email")]
|
||||||
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)
|
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.Device;
|
using API.DTOs.Device;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.SignalR;
|
||||||
|
using ExCSS;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -20,12 +24,14 @@ public class DeviceController : BaseApiController
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IDeviceService _deviceService;
|
private readonly IDeviceService _deviceService;
|
||||||
private readonly IEmailService _emailService;
|
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;
|
_unitOfWork = unitOfWork;
|
||||||
_deviceService = deviceService;
|
_deviceService = deviceService;
|
||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
|
_eventHub = eventHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -76,26 +82,33 @@ public class DeviceController : BaseApiController
|
|||||||
[HttpPost("send-to")]
|
[HttpPost("send-to")]
|
||||||
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
|
public async Task<ActionResult> 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 (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0");
|
||||||
|
|
||||||
if (await _emailService.IsDefaultEmailService())
|
if (await _emailService.IsDefaultEmailService())
|
||||||
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
|
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
|
try
|
||||||
{
|
{
|
||||||
var success = await _deviceService.SendTo(dto.ChapterId, dto.DeviceId);
|
var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId);
|
||||||
if (success) return Ok();
|
if (success) return Ok();
|
||||||
}
|
}
|
||||||
catch (KavitaException ex)
|
catch (KavitaException ex)
|
||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
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");
|
return BadRequest("There was an error sending the file to the device");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
11
API/DTOs/Account/ConfirmEmailUpdateDto.cs
Normal file
11
API/DTOs/Account/ConfirmEmailUpdateDto.cs
Normal file
@ -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; }
|
||||||
|
}
|
6
API/DTOs/Account/UpdateEmailDto.cs
Normal file
6
API/DTOs/Account/UpdateEmailDto.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace API.DTOs.Account;
|
||||||
|
|
||||||
|
public class UpdateEmailDto
|
||||||
|
{
|
||||||
|
public string Email { get; set; }
|
||||||
|
}
|
14
API/DTOs/Account/UpdateEmailResponse.cs
Normal file
14
API/DTOs/Account/UpdateEmailResponse.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace API.DTOs.Account;
|
||||||
|
|
||||||
|
public class UpdateEmailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Did the user not have an existing email
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This informs the user to check the new email address</remarks>
|
||||||
|
public bool HadNoExistingEmail { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Was an email sent (ie is this server accessible)
|
||||||
|
/// </summary>
|
||||||
|
public bool EmailSent { get; set; }
|
||||||
|
}
|
@ -6,11 +6,6 @@ public record UpdateUserDto
|
|||||||
{
|
{
|
||||||
public int UserId { get; set; }
|
public int UserId { get; set; }
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// This field will not result in any change to the User model. Changing email is not supported.
|
|
||||||
/// </summary>
|
|
||||||
public string Email { get; set; }
|
|
||||||
/// <summary>
|
|
||||||
/// List of Roles to assign to user. If admin not present, Pleb will be applied.
|
/// 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.
|
/// If admin present, all libraries will be granted access and will ignore those from DTO.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
namespace API.DTOs.Device;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.Device;
|
||||||
|
|
||||||
public class SendToDeviceDto
|
public class SendToDeviceDto
|
||||||
{
|
{
|
||||||
public int DeviceId { get; set; }
|
public int DeviceId { get; set; }
|
||||||
public int ChapterId { get; set; }
|
public IReadOnlyList<int> ChapterIds { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,9 @@ public class RegisterDto
|
|||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public string Username { get; init; }
|
public string Username { get; init; }
|
||||||
[Required]
|
/// <summary>
|
||||||
|
/// An email to register with. Optional. Provides Forgot Password functionality
|
||||||
|
/// </summary>
|
||||||
public string Email { get; init; }
|
public string Email { get; init; }
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(32, MinimumLength = 6)]
|
[StringLength(32, MinimumLength = 6)]
|
||||||
|
@ -62,6 +62,7 @@ public interface IUserRepository
|
|||||||
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
||||||
Task<bool> HasAccessToLibrary(int libraryId, int userId);
|
Task<bool> HasAccessToLibrary(int libraryId, int userId);
|
||||||
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags);
|
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags);
|
||||||
|
Task<AppUser> GetUserByConfirmationToken(string token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserRepository : IUserRepository
|
public class UserRepository : IUserRepository
|
||||||
@ -268,6 +269,11 @@ public class UserRepository : IUserRepository
|
|||||||
return await query.ToListAsync();
|
return await query.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AppUser> GetUserByConfirmationToken(string token)
|
||||||
|
{
|
||||||
|
return await _context.AppUser.SingleOrDefaultAsync(u => u.ConfirmationToken.Equals(token));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||||
@ -403,10 +409,14 @@ public class UserRepository : IUserRepository
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a list of users that are considered Pending by invite. This means email is unconfirmed and they have never logged in
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
public async Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync()
|
public async Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync()
|
||||||
{
|
{
|
||||||
return await _context.Users
|
return await _context.Users
|
||||||
.Where(u => !u.EmailConfirmed)
|
.Where(u => !u.EmailConfirmed && u.LastActive == DateTime.MinValue)
|
||||||
.Include(x => x.Libraries)
|
.Include(x => x.Libraries)
|
||||||
.Include(r => r.UserRoles)
|
.Include(r => r.UserRoles)
|
||||||
.ThenInclude(r => r.Role)
|
.ThenInclude(r => r.Role)
|
||||||
|
@ -32,7 +32,7 @@ public static class IdentityServiceExtensions
|
|||||||
opt.Password.RequireNonAlphanumeric = false;
|
opt.Password.RequireNonAlphanumeric = false;
|
||||||
opt.Password.RequiredLength = 6;
|
opt.Password.RequiredLength = 6;
|
||||||
|
|
||||||
opt.SignIn.RequireConfirmedEmail = true;
|
opt.SignIn.RequireConfirmedEmail = false;
|
||||||
|
|
||||||
opt.Lockout.AllowedForNewUsers = true;
|
opt.Lockout.AllowedForNewUsers = true;
|
||||||
opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
|
opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
|
||||||
|
@ -7,6 +7,7 @@ using API.DTOs.Device;
|
|||||||
using API.DTOs.Email;
|
using API.DTOs.Email;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.SignalR;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ public interface IDeviceService
|
|||||||
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
|
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
|
||||||
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
|
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
|
||||||
Task<bool> Delete(AppUser userWithDevices, int deviceId);
|
Task<bool> Delete(AppUser userWithDevices, int deviceId);
|
||||||
Task<bool> SendTo(int chapterId, int deviceId);
|
Task<bool> SendTo(IReadOnlyList<int> chapterIds, int deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeviceService : IDeviceService
|
public class DeviceService : IDeviceService
|
||||||
@ -102,9 +103,9 @@ public class DeviceService : IDeviceService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> SendTo(int chapterId, int deviceId)
|
public async Task<bool> SendTo(IReadOnlyList<int> 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)))
|
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");
|
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,
|
DestinationEmail = device.EmailAddress,
|
||||||
FilePaths = files.Select(m => m.FilePath)
|
FilePaths = files.Select(m => m.FilePath)
|
||||||
});
|
});
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Email;
|
using API.DTOs.Email;
|
||||||
@ -14,7 +12,6 @@ using Kavita.Common.EnvironmentInfo;
|
|||||||
using Kavita.Common.Helpers;
|
using Kavita.Common.Helpers;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
|
|
||||||
@ -27,6 +24,7 @@ public interface IEmailService
|
|||||||
Task<bool> SendFilesToEmail(SendToDto data);
|
Task<bool> SendFilesToEmail(SendToDto data);
|
||||||
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
|
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
|
||||||
Task<bool> IsDefaultEmailService();
|
Task<bool> IsDefaultEmailService();
|
||||||
|
Task SendEmailChangeEmail(ConfirmationEmailDto data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EmailService : IEmailService
|
public class EmailService : IEmailService
|
||||||
@ -84,6 +82,16 @@ public class EmailService : IEmailService
|
|||||||
.Equals(DefaultApiUrl);
|
.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)
|
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
|
||||||
{
|
{
|
||||||
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
||||||
@ -172,18 +180,20 @@ public class EmailService : IEmailService
|
|||||||
|
|
||||||
if (response.StatusCode != StatusCodes.Status200OK)
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<bool> SendEmailWithFiles(string url, IEnumerable<string> filePaths, string destEmail, int timeoutSecs = 30)
|
private async Task<bool> SendEmailWithFiles(string url, IEnumerable<string> filePaths, string destEmail, int timeoutSecs = 300)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -193,7 +203,8 @@ public class EmailService : IEmailService
|
|||||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||||
.WithHeader("x-kavita-installId", settings.InstallId)
|
.WithHeader("x-kavita-installId", settings.InstallId)
|
||||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
.WithTimeout(timeoutSecs)
|
||||||
|
.AllowHttpStatus("4xx")
|
||||||
.PostMultipartAsync(mp =>
|
.PostMultipartAsync(mp =>
|
||||||
{
|
{
|
||||||
mp.AddString("email", destEmail);
|
mp.AddString("email", destEmail);
|
||||||
@ -208,10 +219,11 @@ public class EmailService : IEmailService
|
|||||||
|
|
||||||
if (response.StatusCode != StatusCodes.Status200OK)
|
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");
|
_logger.LogError(ex, "There was an exception when sending Email for SendTo");
|
||||||
return false;
|
return false;
|
||||||
|
@ -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
|
// 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 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)
|
foreach (var directory in directories)
|
||||||
{
|
{
|
||||||
@ -94,7 +95,7 @@ public class ParseScannedFiles
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
/// A generic message that can occur in background processing to inform user, but no direct action is needed
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Info = "Info";
|
public const string Info = "Info";
|
||||||
|
/// <summary>
|
||||||
|
/// When files are being emailed to a device
|
||||||
|
/// </summary>
|
||||||
|
public const string SendingToDevice = "SendingToDevice";
|
||||||
|
|
||||||
|
|
||||||
public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName)
|
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)
|
public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId)
|
||||||
{
|
{
|
||||||
return new SignalRMessage
|
return new SignalRMessage
|
||||||
|
10
UI/Web/src/app/_models/email/update-email-response.ts
Normal file
10
UI/Web/src/app/_models/email/update-email-response.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -8,4 +8,5 @@ export interface User {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
preferences: Preferences;
|
preferences: Preferences;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
@ -11,6 +11,7 @@ import { ThemeService } from './theme.service';
|
|||||||
import { InviteUserResponse } from '../_models/invite-user-response';
|
import { InviteUserResponse } from '../_models/invite-user-response';
|
||||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||||
import { DeviceService } from './device.service';
|
import { DeviceService } from './device.service';
|
||||||
|
import { UpdateEmailResponse } from '../_models/email/update-email-response';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -132,6 +133,10 @@ export class AccountService implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEmailConfirmed() {
|
||||||
|
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
|
||||||
|
}
|
||||||
|
|
||||||
migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) {
|
migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) {
|
||||||
return this.httpClient.post<string>(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'});
|
return this.httpClient.post<string>(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'});
|
||||||
}
|
}
|
||||||
@ -152,6 +157,10 @@ export class AccountService implements OnDestroy {
|
|||||||
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email', model);
|
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmEmailUpdate(model: {email: string, token: string}) {
|
||||||
|
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email-update', model);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a user id, returns a full url for setting up the user account
|
* Given a user id, returns a full url for setting up the user account
|
||||||
* @param userId
|
* @param userId
|
||||||
@ -181,6 +190,10 @@ export class AccountService implements OnDestroy {
|
|||||||
return this.httpClient.post(this.baseUrl + 'account/update', model);
|
return this.httpClient.post(this.baseUrl + 'account/update', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEmail(email: string) {
|
||||||
|
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will get latest preferences for a user and cache them into user store
|
* This will get latest preferences for a user and cache them into user store
|
||||||
* @returns
|
* @returns
|
||||||
|
@ -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<Device>) => devices.map(d => {
|
||||||
|
return {'title': d.name, 'data': d};
|
||||||
|
}), shareReplay())),
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: Action.Download,
|
action: Action.Download,
|
||||||
title: 'Download',
|
title: 'Download',
|
||||||
|
@ -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 { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||||
import { ConfirmService } from '../shared/confirm.service';
|
import { ConfirmService } from '../shared/confirm.service';
|
||||||
import { Chapter } from '../_models/chapter';
|
import { Chapter } from '../_models/chapter';
|
||||||
|
import { Device } from '../_models/device/device';
|
||||||
import { Library } from '../_models/library';
|
import { Library } from '../_models/library';
|
||||||
import { ReadingList } from '../_models/reading-list';
|
import { ReadingList } from '../_models/reading-list';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
import { Volume } from '../_models/volume';
|
import { Volume } from '../_models/volume';
|
||||||
|
import { DeviceService } from './device.service';
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
import { MemberService } from './member.service';
|
import { MemberService } from './member.service';
|
||||||
import { ReaderService } from './reader.service';
|
import { ReaderService } from './reader.service';
|
||||||
@ -39,7 +41,7 @@ export class ActionService implements OnDestroy {
|
|||||||
|
|
||||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
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() {
|
ngOnDestroy() {
|
||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
@ -552,6 +554,15 @@ export class ActionService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToDevice(chapterIds: Array<number>, 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 = '') {
|
private async promptIfForce(extraContent: string = '') {
|
||||||
// Prompt user if we should do a force or not
|
// Prompt user if we should do a force or not
|
||||||
const config = this.confirmService.defaultConfirm;
|
const config = this.confirmService.defaultConfirm;
|
||||||
|
@ -40,8 +40,8 @@ export class DeviceService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTo(chapterId: number, deviceId: number) {
|
sendTo(chapterIds: Array<number>, deviceId: number) {
|
||||||
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'});
|
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,31 +51,35 @@ export enum EVENTS {
|
|||||||
/**
|
/**
|
||||||
* A subtype of NotificationProgress that represents a file being processed for cover image extraction
|
* 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
|
* A library is created or removed from the instance
|
||||||
*/
|
*/
|
||||||
LibraryModified = 'LibraryModified',
|
LibraryModified = 'LibraryModified',
|
||||||
/**
|
/**
|
||||||
* A user updates an entities read progress
|
* A user updates an entities read progress
|
||||||
*/
|
*/
|
||||||
UserProgressUpdate = 'UserProgressUpdate',
|
UserProgressUpdate = 'UserProgressUpdate',
|
||||||
/**
|
/**
|
||||||
* A user updates account or preferences
|
* A user updates account or preferences
|
||||||
*/
|
*/
|
||||||
UserUpdate = 'UserUpdate',
|
UserUpdate = 'UserUpdate',
|
||||||
/**
|
/**
|
||||||
* When bulk bookmarks are being converted
|
* When bulk bookmarks are being converted
|
||||||
*/
|
*/
|
||||||
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
|
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
|
||||||
/**
|
/**
|
||||||
* When files are being scanned to calculate word count
|
* 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
|
* 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<T> {
|
export interface Message<T> {
|
||||||
@ -261,6 +265,13 @@ export class MessageHubService {
|
|||||||
payload: resp.body
|
payload: resp.body
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.hubConnection.on(EVENTS.SendingToDevice, resp => {
|
||||||
|
this.messagesSource.next({
|
||||||
|
event: EVENTS.SendingToDevice,
|
||||||
|
payload: resp.body
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stopHubConnection() {
|
stopHubConnection() {
|
||||||
|
@ -45,10 +45,6 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<!-- <div class="form-check form-switch">
|
|
||||||
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
|
|
||||||
<label for="stat-collection" class="form-check-label">Send Data</label>
|
|
||||||
</div> -->
|
|
||||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
email service, by setting up <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the url of the email service and use the Test button to ensure it works.
|
email service, by setting up <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the url of the email service and use the Test button to ensure it works.
|
||||||
At any time you can reset to the default. There is no way to disable emails for authentication, although you are not required to use a
|
At any time you can reset to the default. There is no way to disable emails for authentication, although you are not required to use a
|
||||||
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
|
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
|
||||||
Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publically reachable url.
|
Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publicly reachable url.
|
||||||
<span class="text-warning">If you want Send To device to work, you must host your own email service.</span>
|
<span class="text-warning">If you want Send To device to work, you must host your own email service.</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
@ -13,9 +13,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
AllSeriesRoutingModule,
|
AllSeriesRoutingModule,
|
||||||
|
|
||||||
SharedSideNavCardsModule
|
SharedSideNavCardsModule
|
||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AllSeriesModule { }
|
export class AllSeriesModule { }
|
||||||
|
@ -228,9 +228,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
|
|||||||
case (Action.SendTo):
|
case (Action.SendTo):
|
||||||
{
|
{
|
||||||
const device = (action._extra!.data as Device);
|
const device = (action._extra!.data as Device);
|
||||||
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
|
this.actionService.sendToDevice([chapter.id], device);
|
||||||
this.toastr.success('File emailed to ' + device.name);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
|
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
|
||||||
<div class="grid row g-0" #container>
|
<div class="grid row g-0" #container>
|
||||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -34,7 +34,7 @@
|
|||||||
<ng-template #cardTemplate>
|
<ng-template #cardTemplate>
|
||||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
||||||
<div class="grid row g-0" #container>
|
<div class="grid row g-0" #container>
|
||||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener,
|
||||||
|
Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@ -13,7 +14,6 @@ import { Pagination } from 'src/app/_models/pagination';
|
|||||||
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
||||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||||
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-card-detail-layout',
|
selector: 'app-card-detail-layout',
|
||||||
@ -157,4 +157,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
this.jumpbarService.saveResumeKey(this.router.url, jumpKey.key);
|
this.jumpbarService.saveResumeKey(this.router.url, jumpKey.key);
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tryToSaveJumpKey(item: any) {
|
||||||
|
let name = '';
|
||||||
|
if (item.hasOwnProperty('name')) {
|
||||||
|
name = item.name;
|
||||||
|
} else if (item.hasOwnProperty('title')) {
|
||||||
|
name = item.title;
|
||||||
|
}
|
||||||
|
this.jumpbarService.saveResumeKey(this.router.url, name.charAt(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
<app-splash-container>
|
||||||
|
<ng-container title><h2>Validate Email Change</h2></ng-container>
|
||||||
|
<ng-container body>
|
||||||
|
<p *ngIf="!confirmed; else confirmedMessage">Please wait while your email update is validated.</p>
|
||||||
|
|
||||||
|
<ng-template #confirmedMessage>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<h3><i class="fa-regular fa-circle-check me-2" style="font-size: 1.8rem" aria-hidden="true"></i>Success!</h3>
|
||||||
|
</div>
|
||||||
|
<p>Your email has been validated and is now changed within Kavita. You will be redirected to login.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
</app-splash-container>
|
@ -0,0 +1,7 @@
|
|||||||
|
.card-body {
|
||||||
|
padding: 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
|
import { NavService } from 'src/app/_services/nav.service';
|
||||||
|
import { ThemeService } from 'src/app/_services/theme.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component just validates the email via API then redirects to login
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-email-change',
|
||||||
|
templateUrl: './confirm-email-change.component.html',
|
||||||
|
styleUrls: ['./confirm-email-change.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ConfirmEmailChangeComponent implements OnInit {
|
||||||
|
|
||||||
|
email: string = '';
|
||||||
|
token: string = '';
|
||||||
|
|
||||||
|
confirmed: boolean = false;
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||||
|
private toastr: ToastrService, private themeService: ThemeService, private navService: NavService,
|
||||||
|
private readonly cdRef: ChangeDetectorRef) {
|
||||||
|
this.navService.hideSideNav();
|
||||||
|
this.themeService.setTheme(this.themeService.defaultTheme);
|
||||||
|
const token = this.route.snapshot.queryParamMap.get('token');
|
||||||
|
const email = this.route.snapshot.queryParamMap.get('email');
|
||||||
|
|
||||||
|
if (this.isNullOrEmpty(token) || this.isNullOrEmpty(email)) {
|
||||||
|
// This is not a valid url, redirect to login
|
||||||
|
this.toastr.error('Invalid confirmation url');
|
||||||
|
this.router.navigateByUrl('login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = token!;
|
||||||
|
this.email = email!;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.accountService.confirmEmailUpdate({email: this.email, token: this.token}).subscribe((errors) => {
|
||||||
|
this.confirmed = true;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
setTimeout(() => this.router.navigateByUrl('login'), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isNullOrEmpty(v: string | null | undefined) {
|
||||||
|
return v == undefined || v === '' || v === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -38,19 +38,23 @@ export class ConfirmEmailComponent {
|
|||||||
const token = this.route.snapshot.queryParamMap.get('token');
|
const token = this.route.snapshot.queryParamMap.get('token');
|
||||||
const email = this.route.snapshot.queryParamMap.get('email');
|
const email = this.route.snapshot.queryParamMap.get('email');
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
if (token == undefined || token === '' || token === null) {
|
if (this.isNullOrEmpty(token) || this.isNullOrEmpty(email)) {
|
||||||
// This is not a valid url, redirect to login
|
// This is not a valid url, redirect to login
|
||||||
this.toastr.error('Invalid confirmation email');
|
this.toastr.error('Invalid confirmation url');
|
||||||
this.router.navigateByUrl('login');
|
this.router.navigateByUrl('login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.token = token;
|
this.token = token!;
|
||||||
this.registerForm.get('email')?.setValue(email || '');
|
this.registerForm.get('email')?.setValue(email || '');
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isNullOrEmpty(v: string | null | undefined) {
|
||||||
|
return v == undefined || v === '' || v === null;
|
||||||
|
}
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
let model = this.registerForm.getRawValue();
|
const model = this.registerForm.getRawValue();
|
||||||
model.token = this.token;
|
model.token = this.token;
|
||||||
this.accountService.confirmEmail(model).subscribe((user) => {
|
this.accountService.confirmEmail(model).subscribe((user) => {
|
||||||
this.toastr.success('Account registration complete');
|
this.toastr.success('Account registration complete');
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="mb-3" style="width:100%">
|
<div class="mb-3" style="width:100%">
|
||||||
<label for="email" class="form-label">Email</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
|
<label for="email" class="form-label">Email</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
|
||||||
<ng-template #emailTooltip>Email does not have to be valid, it is used for forgot password flow. It is not sent outside the server unless forgot password is used without a custom email service host.</ng-template>
|
<ng-template #emailTooltip>Email is optional and provides acccess to forgot password. It is not sent outside the server unless forgot password is used without a custom email service host.</ng-template>
|
||||||
<span class="visually-hidden" id="email-help">
|
<span class="visually-hidden" id="email-help">
|
||||||
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
|
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
|
||||||
</span>
|
</span>
|
||||||
|
@ -18,7 +18,7 @@ import { MemberService } from 'src/app/_services/member.service';
|
|||||||
export class RegisterComponent implements OnInit {
|
export class RegisterComponent implements OnInit {
|
||||||
|
|
||||||
registerForm: FormGroup = new FormGroup({
|
registerForm: FormGroup = new FormGroup({
|
||||||
email: new FormControl('', [Validators.required, Validators.email]),
|
email: new FormControl('', [Validators.email]),
|
||||||
username: new FormControl('', [Validators.required]),
|
username: new FormControl('', [Validators.required]),
|
||||||
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
|
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,7 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir
|
|||||||
import { ResetPasswordComponent } from './reset-password/reset-password.component';
|
import { ResetPasswordComponent } from './reset-password/reset-password.component';
|
||||||
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
|
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
|
||||||
import { UserLoginComponent } from './user-login/user-login.component';
|
import { UserLoginComponent } from './user-login/user-login.component';
|
||||||
|
import { ConfirmEmailChangeComponent } from './confirm-email-change/confirm-email-change.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -23,7 +24,8 @@ import { UserLoginComponent } from './user-login/user-login.component';
|
|||||||
ConfirmMigrationEmailComponent,
|
ConfirmMigrationEmailComponent,
|
||||||
ResetPasswordComponent,
|
ResetPasswordComponent,
|
||||||
ConfirmResetPasswordComponent,
|
ConfirmResetPasswordComponent,
|
||||||
UserLoginComponent
|
UserLoginComponent,
|
||||||
|
ConfirmEmailChangeComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
import { ConfirmEmailChangeComponent } from './confirm-email-change/confirm-email-change.component';
|
||||||
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
|
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
|
||||||
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
|
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
|
||||||
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
|
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
|
||||||
@ -24,6 +25,10 @@ const routes: Routes = [
|
|||||||
path: 'confirm-migration-email',
|
path: 'confirm-migration-email',
|
||||||
component: ConfirmMigrationEmailComponent,
|
component: ConfirmMigrationEmailComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'confirm-email-update',
|
||||||
|
component: ConfirmEmailChangeComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'register',
|
path: 'register',
|
||||||
component: RegisterComponent,
|
component: RegisterComponent,
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
||||||
<ng-container title>
|
<ng-container title>
|
||||||
<h2 class="title text-break">
|
<h2 class="title text-break">
|
||||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||||
<span>{{series.name}}</span>
|
<span>{{series.name}}</span>
|
||||||
</h2>
|
</h2>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -81,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-auto ms-2 d-none d-sm-block">
|
<div class="col-auto ms-2 d-none d-sm-block">
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -113,11 +113,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
seriesImage: string = '';
|
seriesImage: string = '';
|
||||||
downloadInProgress: boolean = false;
|
downloadInProgress: boolean = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* If an action is currently being done, don't let the user kick off another action
|
|
||||||
*/
|
|
||||||
actionInProgress: boolean = false;
|
|
||||||
|
|
||||||
itemSize: number = 10; // when 10 done, 16 loads
|
itemSize: number = 10; // when 10 done, 16 loads
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,7 +177,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
case Action.AddToReadingList:
|
case Action.AddToReadingList:
|
||||||
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
|
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
|
||||||
this.actionInProgress = false;
|
|
||||||
if (success) this.bulkSelectionService.deselectAll();
|
if (success) this.bulkSelectionService.deselectAll();
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -190,7 +184,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
case Action.MarkAsRead:
|
case Action.MarkAsRead:
|
||||||
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
|
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -199,7 +192,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
case Action.MarkAsUnread:
|
case Action.MarkAsUnread:
|
||||||
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
|
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -334,70 +326,53 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
|
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
|
||||||
this.actionInProgress = true;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
switch(action.action) {
|
switch(action.action) {
|
||||||
case(Action.MarkAsRead):
|
case(Action.MarkAsRead):
|
||||||
this.actionService.markSeriesAsRead(series, (series: Series) => {
|
this.actionService.markSeriesAsRead(series, (series: Series) => {
|
||||||
this.actionInProgress = false;
|
|
||||||
this.loadSeries(series.id);
|
this.loadSeries(series.id);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case(Action.MarkAsUnread):
|
case(Action.MarkAsUnread):
|
||||||
this.actionService.markSeriesAsUnread(series, (series: Series) => {
|
this.actionService.markSeriesAsUnread(series, (series: Series) => {
|
||||||
this.actionInProgress = false;
|
|
||||||
this.loadSeries(series.id);
|
this.loadSeries(series.id);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case(Action.Scan):
|
case(Action.Scan):
|
||||||
this.actionService.scanSeries(series, () => {
|
this.actionService.scanSeries(series);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case(Action.RefreshMetadata):
|
case(Action.RefreshMetadata):
|
||||||
this.actionService.refreshMetdata(series, () => {
|
this.actionService.refreshMetdata(series);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case(Action.Delete):
|
case(Action.Delete):
|
||||||
this.deleteSeries(series);
|
this.deleteSeries(series);
|
||||||
break;
|
break;
|
||||||
case(Action.AddToReadingList):
|
case(Action.AddToReadingList):
|
||||||
this.actionService.addSeriesToReadingList(series, () => {
|
this.actionService.addSeriesToReadingList(series);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case(Action.AddToCollection):
|
case(Action.AddToCollection):
|
||||||
this.actionService.addMultipleSeriesToCollectionTag([series], () => {
|
this.actionService.addMultipleSeriesToCollectionTag([series]);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case (Action.AnalyzeFiles):
|
case (Action.AnalyzeFiles):
|
||||||
this.actionService.analyzeFilesForSeries(series, () => {
|
this.actionService.analyzeFilesForSeries(series);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case Action.AddToWantToReadList:
|
case Action.AddToWantToReadList:
|
||||||
this.actionService.addMultipleSeriesToWantToReadList([series.id], () => {
|
this.actionService.addMultipleSeriesToWantToReadList([series.id]);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case Action.RemoveFromWantToReadList:
|
case Action.RemoveFromWantToReadList:
|
||||||
this.actionService.removeMultipleSeriesFromWantToReadList([series.id], () => {
|
this.actionService.removeMultipleSeriesFromWantToReadList([series.id]);
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case (Action.Download):
|
case Action.Download:
|
||||||
if (this.downloadInProgress) return;
|
if (this.downloadInProgress) return;
|
||||||
this.downloadSeries();
|
this.downloadSeries();
|
||||||
break;
|
break;
|
||||||
|
case Action.SendTo:
|
||||||
|
{
|
||||||
|
const chapterIds = this.volumes.map(v => v.chapters.map(c => c.id)).flat()
|
||||||
|
const device = (action._extra!.data as Device);
|
||||||
|
this.actionService.sendToDevice(chapterIds, device);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -422,6 +397,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true);
|
this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case (Action.SendTo):
|
||||||
|
{
|
||||||
|
const device = (action._extra!.data as Device);
|
||||||
|
this.actionService.sendToDevice(volume.chapters.map(c => c.id), device);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -447,7 +428,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
case (Action.SendTo):
|
case (Action.SendTo):
|
||||||
{
|
{
|
||||||
const device = (action._extra!.data as Device);
|
const device = (action._extra!.data as Device);
|
||||||
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
|
this.deviceSerivce.sendTo([chapter.id], device.id).subscribe(() => {
|
||||||
this.toastr.success('File emailed to ' + device.name);
|
this.toastr.success('File emailed to ' + device.name);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -460,7 +441,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
|
|
||||||
async deleteSeries(series: Series) {
|
async deleteSeries(series: Series) {
|
||||||
this.actionService.deleteSeries(series, (result: boolean) => {
|
this.actionService.deleteSeries(series, (result: boolean) => {
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
if (result) {
|
if (result) {
|
||||||
this.router.navigate(['library', this.libraryId]);
|
this.router.navigate(['library', this.libraryId]);
|
||||||
@ -604,8 +584,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
|
|
||||||
this.actionService.markVolumeAsRead(this.seriesId, vol, () => {
|
this.actionService.markVolumeAsRead(this.seriesId, vol, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -616,8 +594,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
|
|
||||||
this.actionService.markVolumeAsUnread(this.seriesId, vol, () => {
|
this.actionService.markVolumeAsUnread(this.seriesId, vol, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -628,8 +604,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
|
|
||||||
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
|
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -640,8 +614,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
|
|
||||||
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
|
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
|
||||||
this.setContinuePoint();
|
this.setContinuePoint();
|
||||||
this.actionInProgress = false;
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,6 @@ import { SidenavModule } from '../sidenav/sidenav.module';
|
|||||||
declarations: [],
|
declarations: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|
||||||
CardsModule,
|
CardsModule,
|
||||||
SidenavModule,
|
SidenavModule,
|
||||||
],
|
],
|
||||||
|
@ -7,7 +7,6 @@ import { ReadMoreComponent } from './read-more/read-more.component';
|
|||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { DrawerComponent } from './drawer/drawer.component';
|
import { DrawerComponent } from './drawer/drawer.component';
|
||||||
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
|
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
|
||||||
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
|
|
||||||
import { A11yClickDirective } from './a11y-click.directive';
|
import { A11yClickDirective } from './a11y-click.directive';
|
||||||
import { SeriesFormatComponent } from './series-format/series-format.component';
|
import { SeriesFormatComponent } from './series-format/series-format.component';
|
||||||
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
|
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
|
||||||
@ -25,7 +24,6 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
|
|||||||
ReadMoreComponent,
|
ReadMoreComponent,
|
||||||
DrawerComponent,
|
DrawerComponent,
|
||||||
TagBadgeComponent,
|
TagBadgeComponent,
|
||||||
ShowIfScrollbarDirective,
|
|
||||||
A11yClickDirective,
|
A11yClickDirective,
|
||||||
SeriesFormatComponent,
|
SeriesFormatComponent,
|
||||||
UpdateNotificationModalComponent,
|
UpdateNotificationModalComponent,
|
||||||
@ -50,16 +48,12 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
|
|||||||
A11yClickDirective, // Used globally
|
A11yClickDirective, // Used globally
|
||||||
SeriesFormatComponent, // Used globally
|
SeriesFormatComponent, // Used globally
|
||||||
TagBadgeComponent, // Used globally
|
TagBadgeComponent, // Used globally
|
||||||
CircularLoaderComponent, // Used in Cards only
|
CircularLoaderComponent, // Used in Cards and Series Detail
|
||||||
ImageComponent, // Used globally
|
ImageComponent, // Used globally
|
||||||
|
|
||||||
ShowIfScrollbarDirective, // Used book reader only?
|
|
||||||
|
|
||||||
PersonBadgeComponent, // Used Series Detail
|
PersonBadgeComponent, // Used Series Detail
|
||||||
BadgeExpanderComponent, // Used Series Detail/Metadata
|
BadgeExpanderComponent, // Used Series Detail/Metadata
|
||||||
|
|
||||||
IconAndTitleComponent, // Used in Series Detail/Metadata
|
IconAndTitleComponent, // Used in Series Detail/Metadata
|
||||||
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SharedModule { }
|
export class SharedModule { }
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import { AfterViewInit, Directive, ElementRef, TemplateRef, ViewContainerRef } from '@angular/core';
|
|
||||||
|
|
||||||
// TODO: Fix this code or remove it
|
|
||||||
@Directive({
|
|
||||||
selector: '[appShowIfScrollbar]'
|
|
||||||
})
|
|
||||||
export class ShowIfScrollbarDirective implements AfterViewInit {
|
|
||||||
|
|
||||||
constructor(private el: ElementRef, private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef) {
|
|
||||||
|
|
||||||
}
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
// NOTE: This doesn't work!
|
|
||||||
if (this.el.nativeElement.scrollHeight > this.el.nativeElement.clientHeight) {
|
|
||||||
// If condition is true add template to DOM
|
|
||||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
|
||||||
} else {
|
|
||||||
// Else remove template from DOM
|
|
||||||
this.viewContainer.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,66 @@
|
|||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-11">
|
||||||
|
<h4 id="email-card">Email
|
||||||
|
<ng-container *ngIf="!emailConfirmed">
|
||||||
|
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" ngbTooltip="This email is not confirmed"></i>
|
||||||
|
<span class="visually-hidden">This email is not confirmed</span>
|
||||||
|
</ng-container>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-1">
|
||||||
|
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isViewMode">
|
||||||
|
<span>{{user?.email}}</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||||
|
<ng-container>
|
||||||
|
<div class="alert alert-danger" role="alert" *ngIf="errors.length > 0">
|
||||||
|
<div *ngFor="let error of errors">{{error}}</div>
|
||||||
|
</div>
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label visually-hidden">Email</label>
|
||||||
|
<input class="form-control custom-input" type="email" id="email" formControlName="email"
|
||||||
|
[class.is-invalid]="form.get('email')?.invalid && form.get('email')?.touched">
|
||||||
|
<div id="email-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
|
||||||
|
<div *ngIf="form.get('email')?.errors?.required">
|
||||||
|
This field is required
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||||
|
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="email-card" (click)="resetForm()">Reset</button>
|
||||||
|
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="email-card" (click)="saveForm()" [disabled]="!form.valid || !(form.dirty || form.touched)">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="emailLink !== ''">
|
||||||
|
<h4>Email Updated</h4>
|
||||||
|
<p>You can use the following link below to confirm the email for your account.
|
||||||
|
If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.
|
||||||
|
</p>
|
||||||
|
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
|
||||||
|
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noPermission>
|
||||||
|
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,5 @@
|
|||||||
|
.confirm-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { Observable, of, Subject, takeUntil, shareReplay, map, tap, take } from 'rxjs';
|
||||||
|
import { UpdateEmailResponse } from 'src/app/_models/email/update-email-response';
|
||||||
|
import { User } from 'src/app/_models/user';
|
||||||
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-change-email',
|
||||||
|
templateUrl: './change-email.component.html',
|
||||||
|
styleUrls: ['./change-email.component.scss']
|
||||||
|
})
|
||||||
|
export class ChangeEmailComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
form: FormGroup = new FormGroup({});
|
||||||
|
user: User | undefined = undefined;
|
||||||
|
hasChangePasswordAbility: Observable<boolean> = of(false);
|
||||||
|
passwordsMatch = false;
|
||||||
|
errors: string[] = [];
|
||||||
|
isViewMode: boolean = true;
|
||||||
|
emailLink: string = '';
|
||||||
|
emailConfirmed: boolean = true;
|
||||||
|
|
||||||
|
public get email() { return this.form.get('email'); }
|
||||||
|
|
||||||
|
private onDestroy = new Subject<void>();
|
||||||
|
|
||||||
|
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
|
||||||
|
|
||||||
|
constructor(public accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
|
||||||
|
this.user = user;
|
||||||
|
this.form.addControl('email', new FormControl(user?.email, [Validators.required, Validators.email]));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
this.accountService.isEmailConfirmed().subscribe((confirmed) => {
|
||||||
|
this.emailConfirmed = confirmed;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.form.get('email')?.setValue(this.user?.email);
|
||||||
|
this.errors = [];
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveForm() {
|
||||||
|
if (this.user === undefined) { return; }
|
||||||
|
|
||||||
|
const model = this.form.value;
|
||||||
|
this.errors = [];
|
||||||
|
this.accountService.updateEmail(model.email).subscribe((updateEmailResponse: UpdateEmailResponse) => {
|
||||||
|
if (updateEmailResponse.emailSent) {
|
||||||
|
if (updateEmailResponse.hadNoExistingEmail) {
|
||||||
|
this.toastr.success('An email has been sent to ' + model.email + ' for confirmation.');
|
||||||
|
} else {
|
||||||
|
this.toastr.success('An email has been sent to your old email address for confirmation');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.toastr.success('The server is not publicly accessible. Ask the admin to fetch your confirmation link from the logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetForm();
|
||||||
|
this.isViewMode = true;
|
||||||
|
}, err => {
|
||||||
|
this.errors = err;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleViewMode() {
|
||||||
|
this.isViewMode = !this.isViewMode;
|
||||||
|
this.resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-11"><h4>Password</h4></div>
|
||||||
|
<div class="col-1">
|
||||||
|
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangePasswordAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="isViewMode">
|
||||||
|
<span>***************</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||||
|
<ng-container>
|
||||||
|
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
|
||||||
|
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
|
||||||
|
</div>
|
||||||
|
<form [formGroup]="passwordChangeForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="oldpass" class="form-label">Current Password</label>
|
||||||
|
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
|
||||||
|
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
|
||||||
|
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||||
|
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
|
||||||
|
This field is required
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new-password">New Password</label>
|
||||||
|
<input class="form-control" type="password" id="new-password" formControlName="password"
|
||||||
|
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
|
||||||
|
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||||
|
<div *ngIf="password?.errors?.required">
|
||||||
|
This field is required
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm-password">Confirm Password</label>
|
||||||
|
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations"
|
||||||
|
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
|
||||||
|
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||||
|
<div *ngIf="!passwordsMatch">
|
||||||
|
Passwords must match
|
||||||
|
</div>
|
||||||
|
<div *ngIf="confirmPassword?.errors?.required">
|
||||||
|
This field is required
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||||
|
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">Reset</button>
|
||||||
|
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noPermission>
|
||||||
|
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,82 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||||
|
import { User } from 'src/app/_models/user';
|
||||||
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-change-password',
|
||||||
|
templateUrl: './change-password.component.html',
|
||||||
|
styleUrls: ['./change-password.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
passwordChangeForm: FormGroup = new FormGroup({});
|
||||||
|
user: User | undefined = undefined;
|
||||||
|
hasChangePasswordAbility: Observable<boolean> = of(false);
|
||||||
|
observableHandles: Array<any> = [];
|
||||||
|
passwordsMatch = false;
|
||||||
|
resetPasswordErrors: string[] = [];
|
||||||
|
isViewMode: boolean = true;
|
||||||
|
|
||||||
|
public get password() { return this.passwordChangeForm.get('password'); }
|
||||||
|
public get confirmPassword() { return this.passwordChangeForm.get('confirmPassword'); }
|
||||||
|
|
||||||
|
private onDestroy = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
|
||||||
|
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
|
||||||
|
}));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
|
||||||
|
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
|
||||||
|
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
|
||||||
|
const values = this.passwordChangeForm.value;
|
||||||
|
this.passwordsMatch = values.password === values.confirmPassword;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.observableHandles.forEach(o => o.unsubscribe());
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPasswordForm() {
|
||||||
|
this.passwordChangeForm.get('password')?.setValue('');
|
||||||
|
this.passwordChangeForm.get('confirmPassword')?.setValue('');
|
||||||
|
this.passwordChangeForm.get('oldPassword')?.setValue('');
|
||||||
|
this.resetPasswordErrors = [];
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
savePasswordForm() {
|
||||||
|
if (this.user === undefined) { return; }
|
||||||
|
|
||||||
|
const model = this.passwordChangeForm.value;
|
||||||
|
this.resetPasswordErrors = [];
|
||||||
|
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
|
||||||
|
this.toastr.success('Password has been updated');
|
||||||
|
this.resetPasswordForm();
|
||||||
|
this.isViewMode = true;
|
||||||
|
}, err => {
|
||||||
|
this.resetPasswordErrors = err;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleViewMode() {
|
||||||
|
this.isViewMode = !this.isViewMode;
|
||||||
|
this.resetPasswordForm();
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,11 @@
|
|||||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container *ngIf="tab.fragment === ''">
|
<ng-container *ngIf="tab.fragment === FragmentID.Account">
|
||||||
|
<app-change-email></app-change-email>
|
||||||
|
<app-change-password></app-change-password>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="tab.fragment === FragmentID.Prefernces">
|
||||||
<p>
|
<p>
|
||||||
These are global settings that are bound to your account.
|
These are global settings that are bound to your account.
|
||||||
</p>
|
</p>
|
||||||
@ -289,68 +293,18 @@
|
|||||||
</ngb-accordion>
|
</ngb-accordion>
|
||||||
</form>
|
</form>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="tab.fragment === 'password'">
|
|
||||||
|
|
||||||
<ng-container *ngIf="(hasChangePasswordAbility | async); else noPermission">
|
<ng-container *ngIf="tab.fragment === FragmentID.Clients">
|
||||||
<p>Change your Password</p>
|
|
||||||
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
|
|
||||||
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
|
|
||||||
</div>
|
|
||||||
<form [formGroup]="passwordChangeForm">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="oldpass" class="form-label">Current Password</label>
|
|
||||||
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
|
|
||||||
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
|
|
||||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
|
||||||
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
|
|
||||||
This field is required
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="new-password">New Password</label>
|
|
||||||
<input class="form-control" type="password" id="new-password" formControlName="password"
|
|
||||||
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
|
|
||||||
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
|
||||||
<div *ngIf="password?.errors?.required">
|
|
||||||
This field is required
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="confirm-password">Confirm Password</label>
|
|
||||||
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations"
|
|
||||||
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
|
|
||||||
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
|
||||||
<div *ngIf="!passwordsMatch">
|
|
||||||
Passwords must match
|
|
||||||
</div>
|
|
||||||
<div *ngIf="confirmPassword?.errors?.required">
|
|
||||||
This field is required
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
|
||||||
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">Reset</button>
|
|
||||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #noPermission>
|
|
||||||
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
|
|
||||||
</ng-template>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="tab.fragment === 'clients'">
|
|
||||||
<p>All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.</p>
|
<p>All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.</p>
|
||||||
<p class="alert alert-warning" role="alert" *ngIf="!opdsEnabled">OPDS is not enabled on this server.</p>
|
<p class="alert alert-warning" role="alert" *ngIf="!opdsEnabled">OPDS is not enabled on this server.</p>
|
||||||
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
|
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
|
||||||
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
|
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="tab.fragment === 'theme'">
|
<ng-container *ngIf="tab.fragment === FragmentID.Theme">
|
||||||
<app-theme-manager></app-theme-manager>
|
<app-theme-manager></app-theme-manager>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="tab.fragment === 'devices'">
|
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
|
||||||
<app-manage-devices></app-manage-devices>
|
<app-manage-devices></app-manage-devices>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
import { FormControl, FormGroup } from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { map, shareReplay, take, takeUntil } from 'rxjs/operators';
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { BookService } from 'src/app/book-reader/book.service';
|
import { BookService } from 'src/app/book-reader/book.service';
|
||||||
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences';
|
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences';
|
||||||
@ -11,7 +11,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { SettingsService } from 'src/app/admin/settings.service';
|
import { SettingsService } from 'src/app/admin/settings.service';
|
||||||
import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component';
|
import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component';
|
||||||
import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode';
|
import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode';
|
||||||
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
import { forkJoin, Subject } from 'rxjs';
|
||||||
|
|
||||||
enum AccordionPanelID {
|
enum AccordionPanelID {
|
||||||
ImageReader = 'image-reader',
|
ImageReader = 'image-reader',
|
||||||
@ -19,6 +19,15 @@ enum AccordionPanelID {
|
|||||||
GlobalSettings = 'global-settings'
|
GlobalSettings = 'global-settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FragmentID {
|
||||||
|
Account = 'account',
|
||||||
|
Prefernces = '',
|
||||||
|
Clients = 'clients',
|
||||||
|
Theme = 'theme',
|
||||||
|
Devices = 'devices',
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-preferences',
|
selector: 'app-user-preferences',
|
||||||
templateUrl: './user-preferences.component.html',
|
templateUrl: './user-preferences.component.html',
|
||||||
@ -37,9 +46,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
pageLayoutModes = pageLayoutModes;
|
pageLayoutModes = pageLayoutModes;
|
||||||
|
|
||||||
settingsForm: FormGroup = new FormGroup({});
|
settingsForm: FormGroup = new FormGroup({});
|
||||||
passwordChangeForm: FormGroup = new FormGroup({});
|
|
||||||
user: User | undefined = undefined;
|
user: User | undefined = undefined;
|
||||||
hasChangePasswordAbility: Observable<boolean> = of(false);
|
|
||||||
|
|
||||||
passwordsMatch = false;
|
passwordsMatch = false;
|
||||||
resetPasswordErrors: string[] = [];
|
resetPasswordErrors: string[] = [];
|
||||||
@ -48,11 +55,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
fontFamilies: Array<string> = [];
|
fontFamilies: Array<string> = [];
|
||||||
|
|
||||||
tabs: Array<{title: string, fragment: string}> = [
|
tabs: Array<{title: string, fragment: string}> = [
|
||||||
{title: 'Preferences', fragment: ''},
|
{title: 'Account', fragment: FragmentID.Account},
|
||||||
{title: 'Password', fragment: 'password'},
|
{title: 'Preferences', fragment: FragmentID.Prefernces},
|
||||||
{title: '3rd Party Clients', fragment: 'clients'},
|
{title: '3rd Party Clients', fragment: FragmentID.Clients},
|
||||||
{title: 'Theme', fragment: 'theme'},
|
{title: 'Theme', fragment: FragmentID.Theme},
|
||||||
{title: 'Devices', fragment: 'devices'},
|
{title: 'Devices', fragment: FragmentID.Devices},
|
||||||
];
|
];
|
||||||
active = this.tabs[0];
|
active = this.tabs[0];
|
||||||
opdsEnabled: boolean = false;
|
opdsEnabled: boolean = false;
|
||||||
@ -64,8 +71,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
return AccordionPanelID;
|
return AccordionPanelID;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get password() { return this.passwordChangeForm.get('password'); }
|
get FragmentID() {
|
||||||
public get confirmPassword() { return this.passwordChangeForm.get('confirmPassword'); }
|
return FragmentID;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
|
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
|
||||||
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
|
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
|
||||||
@ -92,11 +101,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.titleService.setTitle('Kavita - User Preferences');
|
this.titleService.setTitle('Kavita - User Preferences');
|
||||||
|
|
||||||
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
|
|
||||||
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
|
|
||||||
}));
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
|
|
||||||
forkJoin({
|
forkJoin({
|
||||||
user: this.accountService.currentUser$.pipe(take(1)),
|
user: this.accountService.currentUser$.pipe(take(1)),
|
||||||
pref: this.accountService.getPreferences()
|
pref: this.accountService.getPreferences()
|
||||||
@ -139,18 +143,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
|
|
||||||
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
|
|
||||||
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
|
|
||||||
const values = this.passwordChangeForm.value;
|
|
||||||
this.passwordsMatch = values.password === values.confirmPassword;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(mode => {
|
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(mode => {
|
||||||
if (mode) {
|
if (mode) {
|
||||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
|
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
|
||||||
@ -194,14 +186,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
this.settingsForm.markAsPristine();
|
this.settingsForm.markAsPristine();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetPasswordForm() {
|
|
||||||
this.passwordChangeForm.get('password')?.setValue('');
|
|
||||||
this.passwordChangeForm.get('confirmPassword')?.setValue('');
|
|
||||||
this.passwordChangeForm.get('oldPassword')?.setValue('');
|
|
||||||
this.resetPasswordErrors = [];
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
if (this.user === undefined) return;
|
if (this.user === undefined) return;
|
||||||
const modelSettings = this.settingsForm.value;
|
const modelSettings = this.settingsForm.value;
|
||||||
@ -240,18 +224,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
savePasswordForm() {
|
|
||||||
if (this.user === undefined) { return; }
|
|
||||||
|
|
||||||
const model = this.passwordChangeForm.value;
|
|
||||||
this.resetPasswordErrors = [];
|
|
||||||
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
|
|
||||||
this.toastr.success('Password has been updated');
|
|
||||||
this.resetPasswordForm();
|
|
||||||
}, err => {
|
|
||||||
this.resetPasswordErrors = err;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
transformKeyToOpdsUrl(key: string) {
|
transformKeyToOpdsUrl(key: string) {
|
||||||
return `${location.origin}/api/opds/${key}`;
|
return `${location.origin}/api/opds/${key}`;
|
||||||
|
@ -13,6 +13,8 @@ import { SidenavModule } from '../sidenav/sidenav.module';
|
|||||||
import { ManageDevicesComponent } from './manage-devices/manage-devices.component';
|
import { ManageDevicesComponent } from './manage-devices/manage-devices.component';
|
||||||
import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
|
import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
|
||||||
import { EditDeviceComponent } from './edit-device/edit-device.component';
|
import { EditDeviceComponent } from './edit-device/edit-device.component';
|
||||||
|
import { ChangePasswordComponent } from './change-password/change-password.component';
|
||||||
|
import { ChangeEmailComponent } from './change-email/change-email.component';
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -24,6 +26,8 @@ import { EditDeviceComponent } from './edit-device/edit-device.component';
|
|||||||
ManageDevicesComponent,
|
ManageDevicesComponent,
|
||||||
DevicePlatformPipe,
|
DevicePlatformPipe,
|
||||||
EditDeviceComponent,
|
EditDeviceComponent,
|
||||||
|
ChangePasswordComponent,
|
||||||
|
ChangeEmailComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user