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:
Joe Milazzo 2022-10-01 08:23:35 -05:00 committed by GitHub
parent 3792ac3421
commit 5f17c2fb73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 816 additions and 274 deletions

View File

@ -19,6 +19,7 @@ using API.Services;
using API.SignalR;
using AutoMapper;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -188,15 +189,6 @@ public class AccountController : BaseApiController
if (user == null) return Unauthorized("Invalid username");
// Check if the user has an email, if not, inform them so they can migrate
var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password);
if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword)
{
_logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName);
return Unauthorized(
"You are missing an email on your account. Please wait while we migrate your account.");
}
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, true);
@ -256,6 +248,7 @@ public class AccountController : BaseApiController
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
// TODO: This should be moved to ServerController
return typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))
@ -285,6 +278,86 @@ public class AccountController : BaseApiController
}
/// <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>
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
/// </summary>
@ -310,14 +383,6 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Update(user);
}
if (!user.Email.Equals(dto.Email))
{
// Validate username change
var errors = await _accountService.ValidateEmail(dto.Email);
if (errors.Any()) return BadRequest("Email already registered");
// NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it
}
// Update roles
var existingRoles = await _userManager.GetRolesAsync(user);
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -404,18 +469,22 @@ public class AccountController : BaseApiController
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
{
var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (adminUser == null) return Unauthorized("You need to login");
if (adminUser == null) return Unauthorized("You are not permitted");
_logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email);
// Check if there is an existing invite
dto.Email = dto.Email.Trim();
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
if (!string.IsNullOrEmpty(dto.Email))
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
dto.Email = dto.Email.Trim();
var emailValidationErrors = await _accountService.ValidateEmail(dto.Email);
if (emailValidationErrors.Any())
{
var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
if (await _userManager.IsEmailConfirmedAsync(invitedUser))
return BadRequest($"User is already registered as {invitedUser.UserName}");
return BadRequest("User is already invited under this email and has yet to accepted invite.");
}
}
// Create a new user
@ -575,6 +644,44 @@ public class AccountController : BaseApiController
};
}
/// <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]
[HttpPost("confirm-password-reset")]
public async Task<ActionResult<string>> ConfirmForgotPassword(ConfirmPasswordResetDto dto)
@ -619,15 +726,15 @@ public class AccountController : BaseApiController
}
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
return Unauthorized("You are not permitted to this operation.");
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
return BadRequest("You do not have an email on account or it has not been confirmed");
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email);
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
_logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
if (await _emailService.CheckIfAccessible(host))
{
@ -643,6 +750,15 @@ public class AccountController : BaseApiController
return Ok("Your server is not accessible. The Link to reset your password is in the logs.");
}
[HttpGet("email-confirmed")]
public async Task<ActionResult<bool>> IsEmailConfirmed()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
return Ok(user.EmailConfirmed);
}
[AllowAnonymous]
[HttpPost("confirm-migration-email")]
public async Task<ActionResult<UserDto>> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto)

View File

@ -1,11 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Device;
using API.Extensions;
using API.Services;
using API.SignalR;
using ExCSS;
using Kavita.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@ -20,12 +24,14 @@ public class DeviceController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly IDeviceService _deviceService;
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService)
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_deviceService = deviceService;
_emailService = emailService;
_eventHub = eventHub;
}
@ -76,26 +82,33 @@ public class DeviceController : BaseApiController
[HttpPost("send-to")]
public async Task<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 (await _emailService.IsDefaultEmailService())
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId);
try
{
var success = await _deviceService.SendTo(dto.ChapterId, dto.DeviceId);
var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId);
if (success) return Ok();
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
finally
{
await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId);
}
return BadRequest("There was an error sending the file to the device");
}
}

View 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; }
}

View File

@ -0,0 +1,6 @@
namespace API.DTOs.Account;
public class UpdateEmailDto
{
public string Email { get; set; }
}

View 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; }
}

View File

@ -6,11 +6,6 @@ public record UpdateUserDto
{
public int UserId { 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.
/// If admin present, all libraries will be granted access and will ignore those from DTO.
/// </summary>

View File

@ -1,7 +1,9 @@
namespace API.DTOs.Device;
using System.Collections.Generic;
namespace API.DTOs.Device;
public class SendToDeviceDto
{
public int DeviceId { get; set; }
public int ChapterId { get; set; }
public IReadOnlyList<int> ChapterIds { get; set; }
}

View File

@ -6,7 +6,9 @@ public class RegisterDto
{
[Required]
public string Username { get; init; }
[Required]
/// <summary>
/// An email to register with. Optional. Provides Forgot Password functionality
/// </summary>
public string Email { get; init; }
[Required]
[StringLength(32, MinimumLength = 6)]

View File

@ -62,6 +62,7 @@ public interface IUserRepository
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags);
Task<AppUser> GetUserByConfirmationToken(string token);
}
public class UserRepository : IUserRepository
@ -268,6 +269,11 @@ public class UserRepository : IUserRepository
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()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -403,10 +409,14 @@ public class UserRepository : IUserRepository
.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()
{
return await _context.Users
.Where(u => !u.EmailConfirmed)
.Where(u => !u.EmailConfirmed && u.LastActive == DateTime.MinValue)
.Include(x => x.Libraries)
.Include(r => r.UserRoles)
.ThenInclude(r => r.Role)

View File

@ -32,7 +32,7 @@ public static class IdentityServiceExtensions
opt.Password.RequireNonAlphanumeric = false;
opt.Password.RequiredLength = 6;
opt.SignIn.RequireConfirmedEmail = true;
opt.SignIn.RequireConfirmedEmail = false;
opt.Lockout.AllowedForNewUsers = true;
opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);

View File

@ -7,6 +7,7 @@ using API.DTOs.Device;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
@ -17,7 +18,7 @@ public interface IDeviceService
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
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
@ -102,9 +103,9 @@ public class DeviceService : IDeviceService
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)))
throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported");
@ -118,6 +119,7 @@ public class DeviceService : IDeviceService
DestinationEmail = device.EmailAddress,
FilePaths = files.Select(m => m.FilePath)
});
return success;
}
}

View File

@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
@ -14,7 +12,6 @@ using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace API.Services;
@ -27,6 +24,7 @@ public interface IEmailService
Task<bool> SendFilesToEmail(SendToDto data);
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
Task<bool> IsDefaultEmailService();
Task SendEmailChangeEmail(ConfirmationEmailDto data);
}
public class EmailService : IEmailService
@ -84,6 +82,16 @@ public class EmailService : IEmailService
.Equals(DefaultApiUrl);
}
public async Task SendEmailChangeEmail(ConfirmationEmailDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
var success = await SendEmailWithPost(emailLink + "/api/account/email-change", data);
if (!success)
{
_logger.LogError("There was a critical error sending Confirmation email");
}
}
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
@ -172,18 +180,20 @@ public class EmailService : IEmailService
if (response.StatusCode != StatusCodes.Status200OK)
{
return false;
var errorMessage = await response.GetStringAsync();
throw new KavitaException(errorMessage);
}
}
catch (Exception)
catch (FlurlHttpException ex)
{
_logger.LogError(ex, "There was an exception when interacting with Email Service");
return false;
}
return true;
}
private async Task<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
{
@ -193,7 +203,8 @@ public class EmailService : IEmailService
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("x-kavita-installId", settings.InstallId)
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
.WithTimeout(timeoutSecs)
.AllowHttpStatus("4xx")
.PostMultipartAsync(mp =>
{
mp.AddString("email", destEmail);
@ -208,10 +219,11 @@ public class EmailService : IEmailService
if (response.StatusCode != StatusCodes.Status200OK)
{
return false;
var errorMessage = await response.GetStringAsync();
throw new KavitaException(errorMessage);
}
}
catch (Exception ex)
catch (FlurlHttpException ex)
{
_logger.LogError(ex, "There was an exception when sending Email for SendTo");
return false;

View File

@ -82,7 +82,8 @@ public class ParseScannedFiles
{
// This is used in library scan, so we should check first for a ignore file and use that here as well
var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile);
var directories = _directoryService.GetDirectories(folderPath, _directoryService.CreateMatcherFromFile(potentialIgnoreFile)).ToList();
var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile);
var directories = _directoryService.GetDirectories(folderPath, matcher).ToList();
foreach (var directory in directories)
{
@ -94,7 +95,7 @@ public class ParseScannedFiles
else
{
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
await folderAction(_directoryService.ScanFiles(directory), directory);
await folderAction(_directoryService.ScanFiles(directory, matcher), directory);
}
}

View File

@ -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
/// </summary>
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)
@ -249,6 +253,19 @@ public static class MessageFactory
};
}
public static SignalRMessage SendingToDeviceEvent(string subtitle, string eventType)
{
return new SignalRMessage
{
Name = SendingToDevice,
Title = "Sending files to Device",
SubTitle = subtitle,
EventType = eventType,
Progress = ProgressType.Indeterminate,
Body = new { }
};
}
public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId)
{
return new SignalRMessage

View 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;
}

View File

@ -8,4 +8,5 @@ export interface User {
roles: string[];
preferences: Preferences;
apiKey: string;
email: string;
}

View File

@ -11,6 +11,7 @@ import { ThemeService } from './theme.service';
import { InviteUserResponse } from '../_models/invite-user-response';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { DeviceService } from './device.service';
import { UpdateEmailResponse } from '../_models/email/update-email-response';
@Injectable({
providedIn: 'root'
@ -132,6 +133,10 @@ export class AccountService implements OnDestroy {
);
}
isEmailConfirmed() {
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
}
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'});
}
@ -152,6 +157,10 @@ export class AccountService implements OnDestroy {
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
* @param userId
@ -181,6 +190,10 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
updateEmail(email: string) {
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns

View File

@ -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,
title: 'Download',

View File

@ -8,10 +8,12 @@ import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-t
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import { ConfirmService } from '../shared/confirm.service';
import { Chapter } from '../_models/chapter';
import { Device } from '../_models/device/device';
import { Library } from '../_models/library';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { DeviceService } from './device.service';
import { LibraryService } from './library.service';
import { MemberService } from './member.service';
import { ReaderService } from './reader.service';
@ -39,7 +41,7 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService) { }
private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { }
ngOnDestroy() {
this.onDestroy.next();
@ -552,6 +554,15 @@ export class ActionService implements OnDestroy {
});
}
sendToDevice(chapterIds: Array<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 = '') {
// Prompt user if we should do a force or not
const config = this.confirmService.defaultConfirm;

View File

@ -40,8 +40,8 @@ export class DeviceService {
}));
}
sendTo(chapterId: number, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'});
sendTo(chapterIds: Array<number>, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'});
}

View File

@ -51,31 +51,35 @@ export enum EVENTS {
/**
* A subtype of NotificationProgress that represents a file being processed for cover image extraction
*/
CoverUpdateProgress = 'CoverUpdateProgress',
CoverUpdateProgress = 'CoverUpdateProgress',
/**
* A library is created or removed from the instance
*/
LibraryModified = 'LibraryModified',
LibraryModified = 'LibraryModified',
/**
* A user updates an entities read progress
*/
UserProgressUpdate = 'UserProgressUpdate',
UserProgressUpdate = 'UserProgressUpdate',
/**
* A user updates account or preferences
*/
UserUpdate = 'UserUpdate',
UserUpdate = 'UserUpdate',
/**
* When bulk bookmarks are being converted
*/
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
/**
* When files are being scanned to calculate word count
*/
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress',
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress',
/**
* When the user needs to be informed, but it's not a big deal
*/
Info = 'Info',
Info = 'Info',
/**
* A user is sending files to their device
*/
SendingToDevice = 'SendingToDevice',
}
export interface Message<T> {
@ -261,6 +265,13 @@ export class MessageHubService {
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SendingToDevice, resp => {
this.messagesSource.next({
event: EVENTS.SendingToDevice,
payload: resp.body
});
});
}
stopHubConnection() {

View File

@ -45,10 +45,6 @@
</div>
<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()">
Cancel
</button>

View File

@ -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.
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.
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>
</p>
<div class="mb-3">

View File

@ -13,9 +13,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
imports: [
CommonModule,
AllSeriesRoutingModule,
SharedSideNavCardsModule
]
})
export class AllSeriesModule { }

View File

@ -228,9 +228,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
});
this.actionService.sendToDevice([chapter.id], device);
break;
}
default:

View File

@ -21,7 +21,7 @@
</p>
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
<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>
</div>
</div>
@ -34,7 +34,7 @@
<ng-template #cardTemplate>
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<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>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
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 { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
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 { ActionItem } from 'src/app/_services/action-factory.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
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.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));
}
}

View File

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

View File

@ -0,0 +1,7 @@
.card-body {
padding: 0px 0px;
}
.card {
background-color: var(--primary-color);
}

View File

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

View File

@ -38,19 +38,23 @@ export class ConfirmEmailComponent {
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
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.toastr.error('Invalid confirmation email');
this.toastr.error('Invalid confirmation url');
this.router.navigateByUrl('login');
return;
}
this.token = token;
this.token = token!;
this.registerForm.get('email')?.setValue(email || '');
this.cdRef.markForCheck();
}
isNullOrEmpty(v: string | null | undefined) {
return v == undefined || v === '' || v === null;
}
submit() {
let model = this.registerForm.getRawValue();
const model = this.registerForm.getRawValue();
model.token = this.token;
this.accountService.confirmEmail(model).subscribe((user) => {
this.toastr.success('Account registration complete');

View File

@ -16,7 +16,7 @@
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>&nbsp;<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">
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
</span>

View File

@ -18,7 +18,7 @@ import { MemberService } from 'src/app/_services/member.service';
export class RegisterComponent implements OnInit {
registerForm: FormGroup = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
email: new FormControl('', [Validators.email]),
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
});

View File

@ -11,6 +11,7 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir
import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.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,
ResetPasswordComponent,
ConfirmResetPasswordComponent,
UserLoginComponent
UserLoginComponent,
ConfirmEmailChangeComponent
],
imports: [
CommonModule,

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
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 { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
@ -24,6 +25,10 @@ const routes: Routes = [
path: 'confirm-migration-email',
component: ConfirmMigrationEmailComponent,
},
{
path: 'confirm-email-update',
component: ConfirmEmailChangeComponent,
},
{
path: 'register',
component: RegisterComponent,

View File

@ -2,7 +2,7 @@
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<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>
</h2>
</ng-container>
@ -81,7 +81,7 @@
</div>
<div class="col-auto ms-2 d-none d-sm-block">
<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>

View File

@ -113,11 +113,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
seriesImage: string = '';
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
/**
@ -182,7 +177,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
this.actionInProgress = false;
if (success) this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -190,7 +184,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case Action.MarkAsRead:
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -199,7 +192,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case Action.MarkAsUnread:
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -334,70 +326,53 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
this.actionInProgress = true;
this.changeDetectionRef.markForCheck();
switch(action.action) {
case(Action.MarkAsRead):
this.actionService.markSeriesAsRead(series, (series: Series) => {
this.actionInProgress = false;
this.loadSeries(series.id);
});
break;
case(Action.MarkAsUnread):
this.actionService.markSeriesAsUnread(series, (series: Series) => {
this.actionInProgress = false;
this.loadSeries(series.id);
});
break;
case(Action.Scan):
this.actionService.scanSeries(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.scanSeries(series);
break;
case(Action.RefreshMetadata):
this.actionService.refreshMetdata(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.refreshMetdata(series);
break;
case(Action.Delete):
this.deleteSeries(series);
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addSeriesToReadingList(series);
break;
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addMultipleSeriesToCollectionTag([series]);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.analyzeFilesForSeries(series);
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList([series.id], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addMultipleSeriesToWantToReadList([series.id]);
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList([series.id], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.removeMultipleSeriesFromWantToReadList([series.id]);
break;
case (Action.Download):
case Action.Download:
if (this.downloadInProgress) return;
this.downloadSeries();
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:
break;
}
@ -422,6 +397,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true);
}
break;
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(volume.chapters.map(c => c.id), device);
break;
}
default:
break;
}
@ -447,7 +428,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case (Action.SendTo):
{
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);
});
break;
@ -460,7 +441,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
async deleteSeries(series: Series) {
this.actionService.deleteSeries(series, (result: boolean) => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
if (result) {
this.router.navigate(['library', this.libraryId]);
@ -604,8 +584,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.actionService.markVolumeAsRead(this.seriesId, vol, () => {
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.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.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.setContinuePoint();
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
}

View File

@ -11,7 +11,6 @@ import { SidenavModule } from '../sidenav/sidenav.module';
declarations: [],
imports: [
CommonModule,
CardsModule,
SidenavModule,
],

View File

@ -7,7 +7,6 @@ import { ReadMoreComponent } from './read-more/read-more.component';
import { RouterModule } from '@angular/router';
import { DrawerComponent } from './drawer/drawer.component';
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.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,
DrawerComponent,
TagBadgeComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
UpdateNotificationModalComponent,
@ -50,16 +48,12 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
A11yClickDirective, // Used globally
SeriesFormatComponent, // Used globally
TagBadgeComponent, // Used globally
CircularLoaderComponent, // Used in Cards only
CircularLoaderComponent, // Used in Cards and Series Detail
ImageComponent, // Used globally
ShowIfScrollbarDirective, // Used book reader only?
PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used Series Detail/Metadata
IconAndTitleComponent, // Used in Series Detail/Metadata
],
})
export class SharedModule { }

View File

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

View File

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

View File

@ -0,0 +1,5 @@
.confirm-icon {
color: var(--primary-color);
font-size: 14px;
margin-bottom: 10px;
}

View File

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

View File

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

View File

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

View File

@ -8,7 +8,11 @@
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<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>
These are global settings that are bound to your account.
</p>
@ -289,68 +293,18 @@
</ngb-accordion>
</form>
</ng-container>
<ng-container *ngIf="tab.fragment === 'password'">
<ng-container *ngIf="(hasChangePasswordAbility | async); else noPermission">
<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'">
<ng-container *ngIf="tab.fragment === FragmentID.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 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 title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
<ng-container *ngIf="tab.fragment === 'theme'">
<ng-container *ngIf="tab.fragment === FragmentID.Theme">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === 'devices'">
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
</ng-template>

View File

@ -1,7 +1,7 @@
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 { map, shareReplay, take, takeUntil } from 'rxjs/operators';
import { take, takeUntil } from 'rxjs/operators';
import { Title } from '@angular/platform-browser';
import { BookService } from 'src/app/book-reader/book.service';
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 { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component';
import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode';
import { forkJoin, Observable, of, Subject } from 'rxjs';
import { forkJoin, Subject } from 'rxjs';
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -19,6 +19,15 @@ enum AccordionPanelID {
GlobalSettings = 'global-settings'
}
enum FragmentID {
Account = 'account',
Prefernces = '',
Clients = 'clients',
Theme = 'theme',
Devices = 'devices',
}
@Component({
selector: 'app-user-preferences',
templateUrl: './user-preferences.component.html',
@ -37,9 +46,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
pageLayoutModes = pageLayoutModes;
settingsForm: FormGroup = new FormGroup({});
passwordChangeForm: FormGroup = new FormGroup({});
user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
passwordsMatch = false;
resetPasswordErrors: string[] = [];
@ -48,11 +55,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
fontFamilies: Array<string> = [];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Preferences', fragment: ''},
{title: 'Password', fragment: 'password'},
{title: '3rd Party Clients', fragment: 'clients'},
{title: 'Theme', fragment: 'theme'},
{title: 'Devices', fragment: 'devices'},
{title: 'Account', fragment: FragmentID.Account},
{title: 'Preferences', fragment: FragmentID.Prefernces},
{title: '3rd Party Clients', fragment: FragmentID.Clients},
{title: 'Theme', fragment: FragmentID.Theme},
{title: 'Devices', fragment: FragmentID.Devices},
];
active = this.tabs[0];
opdsEnabled: boolean = false;
@ -64,8 +71,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
return AccordionPanelID;
}
public get password() { return this.passwordChangeForm.get('password'); }
public get confirmPassword() { return this.passwordChangeForm.get('confirmPassword'); }
get FragmentID() {
return FragmentID;
}
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
@ -92,11 +101,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
ngOnInit(): void {
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({
user: this.accountService.currentUser$.pipe(take(1)),
pref: this.accountService.getPreferences()
@ -139,18 +143,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
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 => {
if (mode) {
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
@ -194,14 +186,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
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() {
if (this.user === undefined) return;
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) {
return `${location.origin}/api/opds/${key}`;

View File

@ -13,6 +13,8 @@ import { SidenavModule } from '../sidenav/sidenav.module';
import { ManageDevicesComponent } from './manage-devices/manage-devices.component';
import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
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({
@ -24,6 +26,8 @@ import { EditDeviceComponent } from './edit-device/edit-device.component';
ManageDevicesComponent,
DevicePlatformPipe,
EditDeviceComponent,
ChangePasswordComponent,
ChangeEmailComponent,
],
imports: [
CommonModule,