From 4e1c66331fb36bb1e3fa46126d8f12c5b6709597 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 6 Jan 2024 10:33:56 -0600 Subject: [PATCH] Random Bugs (#2531) --- .github/DISCUSSION_TEMPLATE/ideas.yml | 69 ++++++++++++ API/Controllers/AccountController.cs | 34 ++++-- API/DTOs/Account/InviteUserResponse.cs | 4 + API/DTOs/Account/UpdateEmailResponse.cs | 14 --- API/Helpers/Builders/VolumeBuilder.cs | 1 + API/Logging/LogLevelOptions.cs | 26 ++++- API/Middleware/CustomAuthHeaderMiddleware.cs | 101 ------------------ API/Services/AccountService.cs | 2 +- API/Services/BookService.cs | 1 - API/Services/Tasks/Scanner/ProcessSeries.cs | 5 - API/Startup.cs | 1 - .../app/_models/auth/invite-user-response.ts | 6 +- .../app/_models/auth/update-email-response.ts | 10 -- UI/Web/src/app/_services/account.service.ts | 10 +- .../admin/edit-user/edit-user.component.html | 3 + .../admin/edit-user/edit-user.component.ts | 6 +- .../invite-user/invite-user.component.html | 8 +- .../invite-user/invite-user.component.ts | 15 ++- .../manage-users/manage-users.component.ts | 2 +- .../card-detail-layout.component.ts | 3 +- .../series-detail/series-detail.component.ts | 2 - .../side-nav/side-nav.component.html | 2 +- .../side-nav/side-nav.component.ts | 4 +- .../change-email/change-email.component.html | 10 +- .../change-email/change-email.component.ts | 27 +++-- UI/Web/src/assets/langs/en.json | 12 ++- openapi.json | 32 +++++- 27 files changed, 232 insertions(+), 178 deletions(-) create mode 100644 .github/DISCUSSION_TEMPLATE/ideas.yml delete mode 100644 API/DTOs/Account/UpdateEmailResponse.cs delete mode 100644 API/Middleware/CustomAuthHeaderMiddleware.cs delete mode 100644 UI/Web/src/app/_models/auth/update-email-response.ts diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 000000000..728b5b497 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,69 @@ +title: "[Kavita] Idea Submission" +labels: ["Idea Submission"] +body: + - type: markdown + attributes: + value: | + ## 🌟 Idea Submission for Kavita 🌟 + + This is a template for submitting your ideas to enhance Kavita. Please fill out the details below, and let's make Kavita even better together! + + - type: textarea + id: idea-description + attributes: + label: Idea Description + description: "Describe your idea in detail." + value: | + [Include a brief overview of your idea] + + - type: markdown + attributes: + value: | + **Why I Think This Is Important:** + + [Provide context on why you believe this idea is valuable or necessary for Kavita users] + + - type: markdown + attributes: + value: | + **How You Can Contribute:** + + 1. **Upvote if You Agree:** + - If you resonate with my idea, please upvote it! This helps us gauge community interest. + + 2. **Leave Your Thoughts:** + - Feel free to leave comments with your opinions, suggestions, or even constructive critiques. + + Let's work together to shape the future of Kavita! 🌟 + + - type: input + id: duration-of-use + attributes: + label: Duration of Using Kavita + description: "How long have you been using Kavita?" + validations: + required: true + + - type: dropdown + id: idea-category + attributes: + label: Idea Category + options: + - Feature Enhancement + - User Experience + - Performance Improvement + description: "Select the category that best fits your idea." + validations: + required: true + + - type: checkboxes + attributes: + label: Agreement + options: + - label: "I agree that this is solely for submitting ideas, and I will search for existing ideas before posting." + required: true + + - type: markdown + attributes: + value: | + ### Thank you for contributing to Kavita's future! 🚀 diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 5e528c58f..c220eb6c0 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -193,7 +193,7 @@ public class AccountController : BaseApiController { user = await _userManager.Users .Include(u => u.UserPreferences) - .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); + .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpperInvariant()); } _logger.LogInformation("{UserName} attempting to login from {IpAddress}", loginDto.Username, HttpContext.Connection.RemoteIpAddress?.ToString()); @@ -390,7 +390,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = false + EmailSent = false, + InvalidEmail = true, }); } @@ -484,6 +485,7 @@ public class AccountController : BaseApiController var errors = await _accountService.ValidateUsername(dto.Username); if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "username-taken")); user.UserName = dto.Username; + await _userManager.UpdateNormalizedUserNameAsync(user); _unitOfWork.UserRepository.Update(user); } @@ -689,7 +691,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = emailLink, - EmailSent = false + EmailSent = false, + InvalidEmail = true }); } @@ -974,13 +977,12 @@ public class AccountController : BaseApiController var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email); - _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); - _logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token); + _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", user.UserName, emailLink); if (!_emailService.IsValidEmail(user.Email)) { - _logger.LogCritical("[Email Migration]: User is trying to resend an invite flow, but their email ({Email}) isn't valid. No email will be send", user.Email); - return Ok(await _localizationService.Translate(user.Id, "invalid-email")); + _logger.LogCritical("[Email Migration]: User {UserName} is trying to resend an invite flow, but their email ({Email}) isn't valid. No email will be send", user.UserName, user.Email); + return BadRequest(await _localizationService.Translate(user.Id, "invalid-email")); } if (await _accountService.CheckIfAccessible(Request)) @@ -1003,7 +1005,7 @@ public class AccountController : BaseApiController return Ok(emailLink); } - return Ok(await _localizationService.Translate(user.Id, "not-accessible")); + return BadRequest(await _localizationService.Translate(user.Id, "not-accessible")); } /// @@ -1102,12 +1104,26 @@ public class AccountController : BaseApiController baseUrl = baseUrl.Replace("//", "/"); } - if (baseUrl.StartsWith("/")) + if (baseUrl.StartsWith('/')) { baseUrl = baseUrl.Substring(1, baseUrl.Length - 1); } } return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey); + } + + /// + /// Is the user's current email valid or not + /// + /// + [HttpGet("is-email-valid")] + public async Task> IsEmailValid() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); + if (user == null) return Unauthorized(); + if (string.IsNullOrEmpty(user.Email)) return Ok(false); + + return Ok(_emailService.IsValidEmail(user.Email)); } } diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs index 97d7f408c..a7e0d86ea 100644 --- a/API/DTOs/Account/InviteUserResponse.cs +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -10,4 +10,8 @@ public class InviteUserResponse /// Was an email sent (ie is this server accessible) /// public bool EmailSent { get; set; } = default!; + /// + /// When a user has an invalid email and is attempting to perform a flow. + /// + public bool InvalidEmail { get; set; } = false; } diff --git a/API/DTOs/Account/UpdateEmailResponse.cs b/API/DTOs/Account/UpdateEmailResponse.cs deleted file mode 100644 index 4f9b816c1..000000000 --- a/API/DTOs/Account/UpdateEmailResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace API.DTOs.Account; - -public class UpdateEmailResponse -{ - /// - /// Did the user not have an existing email - /// - /// This informs the user to check the new email address - public bool HadNoExistingEmail { get; set; } - /// - /// Was an email sent (ie is this server accessible) - /// - public bool EmailSent { get; set; } -} diff --git a/API/Helpers/Builders/VolumeBuilder.cs b/API/Helpers/Builders/VolumeBuilder.cs index 057c3bd99..aabde6ce2 100644 --- a/API/Helpers/Builders/VolumeBuilder.cs +++ b/API/Helpers/Builders/VolumeBuilder.cs @@ -15,6 +15,7 @@ public class VolumeBuilder : IEntityBuilder _volume = new Volume() { Name = volumeNumber, + // TODO / BUG: Try to use float based Number which will allow Epub's with < 1 volumes to show in series detail Number = (int) Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), Chapters = new List() }; diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index 1b4a2d9a8..3771a28fe 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -1,4 +1,5 @@ -using Serilog; +using System.Text.RegularExpressions; +using Serilog; using Serilog.Core; using Serilog.Events; using Serilog.Formatting.Display; @@ -49,6 +50,7 @@ public static class LogLevelOptions .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error) .Enrich.FromLogContext() .Enrich.WithThreadId() + .Enrich.With(new ApiKeyEnricher()) .WriteTo.Console(new MessageTemplateTextFormatter(outputTemplate)) .WriteTo.File(LogFile, shared: true, @@ -74,6 +76,7 @@ public static class LogLevelOptions if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/api/health") return false; if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/hubs/messages") return false; } + return true; } @@ -115,3 +118,24 @@ public static class LogLevelOptions } } + +public partial class ApiKeyEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent e, ILogEventPropertyFactory propertyFactory) + { + var isRequestLoggingMiddleware = e.Properties.ContainsKey("SourceContext") && + e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) == + "Serilog.AspNetCore.RequestLoggingMiddleware"; + if (!isRequestLoggingMiddleware) return; + if (!e.Properties.ContainsKey("RequestPath") || + !e.Properties["RequestPath"].ToString().Contains("apiKey=")) return; + + // Check if the log message contains "apiKey=" and censor it + var censoredMessage = MyRegex().Replace(e.Properties["RequestPath"].ToString(), "apiKey=******REDACTED******"); + var enrichedProperty = propertyFactory.CreateProperty("RequestPath", censoredMessage); + e.AddOrUpdateProperty(enrichedProperty); + } + + [GeneratedRegex(@"\bapiKey=[^&\s]+\b")] + private static partial Regex MyRegex(); +} diff --git a/API/Middleware/CustomAuthHeaderMiddleware.cs b/API/Middleware/CustomAuthHeaderMiddleware.cs deleted file mode 100644 index 74d95a6dc..000000000 --- a/API/Middleware/CustomAuthHeaderMiddleware.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using API.Data; -using API.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace API.Middleware; - -public class CustomAuthHeaderMiddleware(RequestDelegate next) -{ - // Hardcoded list of allowed IP addresses in CIDR format - private readonly string[] allowedIpAddresses = { "192.168.1.0/24", "2001:db8::/32", "116.202.233.5", "104.21.81.112" }; - - - public async Task Invoke(HttpContext context, IUnitOfWork unitOfWork, ILogger logger, ITokenService tokenService) - { - // Extract user information from the custom header - string remoteUser = context.Request.Headers["Remote-User"]; - - // If header missing or user already authenticated, move on - if (string.IsNullOrEmpty(remoteUser) || context.User.Identity is {IsAuthenticated: true}) - { - await next(context); - return; - } - - // Validate IP address - if (IsValidIpAddress(context.Connection.RemoteIpAddress)) - { - // Perform additional authentication logic if needed - // For now, you can log the authenticated user - var user = await unitOfWork.UserRepository.GetUserByEmailAsync(remoteUser); - if (user == null) - { - // Tell security log maybe? - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return; - } - // Check if the RemoteUser has an account on the server - // if (!context.Request.Path.Equals("/login", StringComparison.OrdinalIgnoreCase)) - // { - // // Attach the Auth header and allow it to pass through - // var token = await tokenService.CreateToken(user); - // context.Request.Headers.Add("Authorization", $"Bearer {token}"); - // //context.Response.Redirect($"/login?apiKey={user.ApiKey}"); - // return; - // } - // Attach the Auth header and allow it to pass through - var token = await tokenService.CreateToken(user); - context.Request.Headers.Append("Authorization", $"Bearer {token}"); - await next(context); - return; - } - - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await next(context); - } - - private bool IsValidIpAddress(IPAddress ipAddress) - { - // Check if the IP address is in the whitelist - return allowedIpAddresses.Any(ipRange => IpAddressRange.Parse(ipRange).Contains(ipAddress)); - } -} - -// Helper class for IP address range parsing -public class IpAddressRange -{ - private readonly uint _startAddress; - private readonly uint _endAddress; - - private IpAddressRange(uint startAddress, uint endAddress) - { - _startAddress = startAddress; - _endAddress = endAddress; - } - - public bool Contains(IPAddress address) - { - var ipAddressBytes = address.GetAddressBytes(); - var ipAddress = BitConverter.ToUInt32(ipAddressBytes.Reverse().ToArray(), 0); - return ipAddress >= _startAddress && ipAddress <= _endAddress; - } - - public static IpAddressRange Parse(string ipRange) - { - var parts = ipRange.Split('/'); - var ipAddress = IPAddress.Parse(parts[0]); - var maskBits = int.Parse(parts[1]); - - var ipBytes = ipAddress.GetAddressBytes().Reverse().ToArray(); - var startAddress = BitConverter.ToUInt32(ipBytes, 0); - var endAddress = startAddress | (uint.MaxValue >> maskBits); - - return new IpAddressRange(startAddress, endAddress); - } -} diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 4b03646e8..aebc24ed7 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -73,7 +73,7 @@ public class AccountService : IAccountService basePart = serverSettings.HostName; if (!serverSettings.BaseUrl.Equals(Configuration.DefaultBaseUrl)) { - var removeCount = serverSettings.BaseUrl.EndsWith("/") ? 2 : 1; + var removeCount = serverSettings.BaseUrl.EndsWith('/') ? 1 : 0; basePart += serverSettings.BaseUrl.Substring(0, serverSettings.BaseUrl.Length - removeCount); } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 7b0ecbbbb..19dedde5c 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -546,7 +546,6 @@ public class BookService : IBookService ExtractSortTitle(metadataItem, epubBook, info); } - break; } } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 1cf179139..7815a5bcf 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -510,7 +510,6 @@ public class ProcessSeries : IProcessSeries public void UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false) { - var startingVolumeCount = series.Volumes.Count; // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); @@ -582,10 +581,6 @@ public class ProcessSeries : IProcessSeries series.Volumes = nonDeletedVolumes; } - - // DO I need this anymore? - _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from count of {StartingVolumeCount} to {VolumeCount}", - series.Name, startingVolumeCount, series.Volumes.Count); } public void UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) diff --git a/API/Startup.cs b/API/Startup.cs index b108d13f7..939bfb586 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -261,7 +261,6 @@ public class Startup app.UseMiddleware(); app.UseMiddleware(); - app.UseMiddleware(); if (env.IsDevelopment()) diff --git a/UI/Web/src/app/_models/auth/invite-user-response.ts b/UI/Web/src/app/_models/auth/invite-user-response.ts index a9042c555..4a6e29dc6 100644 --- a/UI/Web/src/app/_models/auth/invite-user-response.ts +++ b/UI/Web/src/app/_models/auth/invite-user-response.ts @@ -7,4 +7,8 @@ export interface InviteUserResponse { * If an email was sent to the invited user */ emailSent: boolean; -} \ No newline at end of file + /** + * When a user has an invalid email and is attempting to perform a flow. + */ + invalidEmail: boolean; +} diff --git a/UI/Web/src/app/_models/auth/update-email-response.ts b/UI/Web/src/app/_models/auth/update-email-response.ts deleted file mode 100644 index eaaf64580..000000000 --- a/UI/Web/src/app/_models/auth/update-email-response.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface UpdateEmailResponse { - /** - * Did the user not have an existing email - */ - hadNoExistingEmail: boolean; - /** - * Was an email sent (ie is this server accessible) - */ - emailSent: boolean; -} \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 560303dbb..23e377411 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -10,12 +10,10 @@ import { EVENTS, MessageHubService } from './message-hub.service'; import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/auth/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { UpdateEmailResponse } from '../_models/auth/update-email-response'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRestriction } from '../_models/metadata/age-restriction'; import { TextResonse } from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {ToastrService} from "ngx-toastr"; export enum Role { Admin = 'Admin', @@ -31,7 +29,6 @@ export enum Role { export class AccountService { private readonly destroyRef = inject(DestroyRef); - private readonly toastr = inject(ToastrService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -192,8 +189,9 @@ export class AccountService { return this.httpClient.get(this.baseUrl + 'account/email-confirmed'); } - migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { - return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, TextResonse); + isEmailValid() { + return this.httpClient.get(this.baseUrl + 'account/is-email-valid', TextResonse) + .pipe(map(res => res == "true")); } confirmMigrationEmail(model: {email: string, token: string}) { @@ -247,7 +245,7 @@ export class AccountService { } updateEmail(email: string, password: string) { - return this.httpClient.post(this.baseUrl + 'account/update/email', {email, password}); + return this.httpClient.post(this.baseUrl + 'account/update/email', {email, password}); } updateAgeRestriction(ageRating: AgeRating, includeUnknowns: boolean) { diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index e3ffa68ae..d4998ca8b 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -19,6 +19,9 @@
{{t('required')}}
+
+ {{t('username-pattern', {characters: allowedCharacters})}} +
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index ba65bae6c..47c8e3afa 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -12,6 +12,8 @@ import { RoleSelectorComponent } from '../role-selector/role-selector.component' import { NgIf } from '@angular/common'; import {TranslocoDirective} from "@ngneat/transloco"; +const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/; + @Component({ selector: 'app-edit-user', templateUrl: './edit-user.component.html', @@ -30,6 +32,8 @@ export class EditUserComponent implements OnInit { userForm: FormGroup = new FormGroup({}); + allowedCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/'; + public get email() { return this.userForm.get('email'); } public get username() { return this.userForm.get('username'); } public get password() { return this.userForm.get('password'); } @@ -39,7 +43,7 @@ export class EditUserComponent implements OnInit { ngOnInit(): void { this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); - this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required])); + this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.get('email')?.disable(); this.selectedRestriction = this.member.ageRestriction; diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 6d4a27607..7fff74fe6 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -39,8 +39,12 @@

{{t('setup-user-title')}}

-

{{t('setup-user-description')}} -

+

{{t('setup-user-description')}}

+ @if (inviteError) { + + }
diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts index 616697a94..1e6a64605 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.ts +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -34,6 +34,7 @@ export class InviteUserComponent implements OnInit { selectedRestriction: AgeRestriction = {ageRating: AgeRating.NotApplicable, includeUnknowns: false}; emailLink: string = ''; invited: boolean = false; + inviteError: boolean = false; private readonly cdRef = inject(ChangeDetectorRef); @@ -65,14 +66,24 @@ export class InviteUserComponent implements OnInit { this.emailLink = data.emailLink; this.isSending = false; this.invited = true; + this.cdRef.markForCheck(); + + if (data.invalidEmail) { + this.toastr.info(translate('toasts.email-not-sent')); + this.inviteError = true; + this.cdRef.markForCheck(); + return; + } + if (data.emailSent) { this.toastr.info(translate('toasts.email-sent', {email: email})); this.modal.close(true); } - this.cdRef.markForCheck(); + }, err => { + // Note to self: If you need to catch an error, do it, but don't toast because interceptor handles that this.isSending = false; - this.toastr.error(err) + this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 0cf60a521..1394a2eca 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -115,7 +115,7 @@ export class ManageUsersComponent implements OnInit { this.serverService.isServerAccessible().subscribe(canAccess => { this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => { if (canAccess) { - this.toastr.info(this.translocoService.translate('toasts.email-sent-to-user', {user: member.username})); + this.toastr.info(this.translocoService.translate('toasts.email-sent', {user: member.username})); return; } await this.confirmService.alert( diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 9ff7aa4fe..a079335a4 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -1,7 +1,5 @@ import {CommonModule, DOCUMENT} from '@angular/common'; import { - afterNextRender, - AfterRenderPhase, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -172,6 +170,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { } hasCustomSort() { + if (this.filteringDisabled) return false; return this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending || this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName || !this.filterSettings?.presetsV2?.sortOptions?.isAscending; } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index a5a92db09..80bb4a99e 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -318,8 +318,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { const companionHeight = this.companionBar!.nativeElement.offsetHeight; const navbarHeight = navbar.offsetHeight; const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding - console.log('compainionHeight: ', companionHeight) - console.log('navbarHeight: ', navbarHeight) return 'calc(var(--vh)*100 - ' + totalHeight + 'px)'; } diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 2d58be002..aea614fca 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -73,6 +73,6 @@
- +
diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index bd30c27bd..8e4de95b0 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -7,7 +7,7 @@ import { OnInit } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators'; import { ImportCblModalComponent } from 'src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component'; import { ImageService } from 'src/app/_services/image.service'; @@ -34,7 +34,7 @@ import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.en @Component({ selector: 'app-side-nav', standalone: true, - imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe], + imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe, NgbTooltip], templateUrl: './side-nav.component.html', styleUrls: ['./side-nav.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.html b/UI/Web/src/app/user-settings/change-email/change-email.component.html index e736ad450..84b7798f2 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.html +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.html @@ -29,14 +29,22 @@
{{error}}
+ @if(!hasValidEmail) { + + }
-
+
{{t('required-field')}}
+
+ {{t('valid-email')}} +
diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.ts b/UI/Web/src/app/user-settings/change-email/change-email.component.ts index 597605f36..f320a6dfb 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.ts +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.ts @@ -2,13 +2,12 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import {ToastrService} from 'ngx-toastr'; import {shareReplay, take} from 'rxjs'; -import {UpdateEmailResponse} from 'src/app/_models/auth/update-email-response'; import {User} from 'src/app/_models/user'; import {AccountService} from 'src/app/_services/account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { ApiKeyComponent } from '../api-key/api-key.component'; import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgFor } from '@angular/common'; +import {NgIf, NgFor, JsonPipe} from '@angular/common'; import {translate, TranslocoDirective} from "@ngneat/transloco"; @Component({ @@ -17,17 +16,20 @@ import {translate, TranslocoDirective} from "@ngneat/transloco"; styleUrls: ['./change-email.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgbTooltip, NgbCollapse, NgFor, ReactiveFormsModule, ApiKeyComponent, TranslocoDirective] + imports: [NgIf, NgbTooltip, NgbCollapse, NgFor, ReactiveFormsModule, ApiKeyComponent, TranslocoDirective, JsonPipe] }) export class ChangeEmailComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + form: FormGroup = new FormGroup({}); user: User | undefined = undefined; errors: string[] = []; isViewMode: boolean = true; emailLink: string = ''; emailConfirmed: boolean = true; - private readonly destroyRef = inject(DestroyRef); + hasValidEmail: boolean = true; + public get email() { return this.form.get('email'); } @@ -45,6 +47,10 @@ export class ChangeEmailComponent implements OnInit { this.emailConfirmed = confirmed; this.cdRef.markForCheck(); }); + this.accountService.isEmailValid().subscribe(isValid => { + this.hasValidEmail = isValid; + this.cdRef.markForCheck(); + }); }); } @@ -59,13 +65,14 @@ export class ChangeEmailComponent implements OnInit { const model = this.form.value; this.errors = []; - this.accountService.updateEmail(model.email, model.password).subscribe((updateEmailResponse: UpdateEmailResponse) => { + this.accountService.updateEmail(model.email, model.password).subscribe(updateEmailResponse => { + + if (updateEmailResponse.invalidEmail) { + this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email})); + } + if (updateEmailResponse.emailSent) { - if (updateEmailResponse.hadNoExistingEmail) { - this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email})); - } else { - this.toastr.success(translate('toasts.email-sent-to')); - } + this.toastr.success(translate('toasts.email-sent-to')); } else { this.toastr.success(translate('toasts.change-email-private')); } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index d3c6a9839..caff3bfd0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -250,9 +250,11 @@ "setup-user-account": "Setup user's account", "invite-url-label": "Invite Url", "invite-url-tooltip": "Copy this and paste in a new tab", + "has-invalid-email": "It looks like you do not have a valid email set. Change email will require the admin to send you a link to complete this action.", "permission-error": "You do not have permission to change your email. Reach out to the admin of the server.", "required-field": "{{validation.required-field}}", + "valid-email": "{{validation.valid-email}}", "reset": "{{common.reset}}", "edit": "{{common.edit}}", "cancel": "{{common.cancel}}", @@ -562,7 +564,9 @@ "invite-url-label": "Invite Url", "invite": "Invite", "inviting": "Inviting…", - "cancel": "{{common.cancel}}" + "cancel": "{{common.cancel}}", + "email-not-sent": "{{toasts.email-not-sent}}", + "notice": "{{manage-settings.notice}}" }, "library-selector": { @@ -772,6 +776,7 @@ "all-series": "All Series", "clear": "{{common.clear}}", "donate": "Donate", + "donate-tooltip": "You can remove this by subscribing to Kavita+", "back": "Back", "more": "More" }, @@ -1252,6 +1257,7 @@ "delete-user-alt": "Delete User {{user}}", "edit-user-tooltip": "Edit", "edit-user-alt": "Edit User {{user}}", + "username-pattern": "Username can only contain the following characters and whitespace: {{characters}}", "resend-invite-tooltip": "Resend Invite", "resend-invite-alt": "Resend Invite {{user}}", "setup-user-tooltip": "Setup User", @@ -1924,7 +1930,6 @@ "no-updates": "No updates available", "confirm-delete-user": "Are you sure you want to delete this user?", "user-deleted": "{{user}} has been deleted", - "email-sent-to-user": "Email sent to {{user}}", "click-email-link": "Please click this link to confirm your email. You must confirm to be able to login.", "series-added-to-collection": "Series added to {{collectionName}} collection", "no-series-collection-warning": "Warning! No series are selected, saving will delete the Collection. Are you sure you want to continue?", @@ -1955,6 +1960,7 @@ "file-send-to": "File(s) emailed to {{name}}", "theme-missing": "The active theme no longer exists. Please refresh the page.", "email-sent": "Email sent to {{email}}", + "email-not-sent": "Email on file is not a valid email and can not be sent. A link has been dumped in logs. The admin can provide this link to complete flow.", "k+-license-saved": "License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.", "k+-unlocked": "Kavita+ unlocked!", "k+-error": "There was an error when activating your license. Please try again.", @@ -1976,7 +1982,7 @@ "library-created": "Library created successfully. A scan has been started.", "anilist-token-updated": "AniList Token has been updated", "age-restriction-updated": "Age Restriction has been updated", - "email-sent-to-no-existing": "An email has been sent to {{email}} for confirmation.", + "email-sent-to-no-existing": "Existing email is not valid. A link has been dumped to logs. Ask admin for link to complete email change.", "email-sent-to": "An email has been sent to your old email address for confirmation.", "change-email-private": "The server is not publicly accessible. Ask the admin to fetch your confirmation link from the logs", "device-updated": "Device updated", diff --git a/openapi.json b/openapi.json index 0b8bc25ef..ea8382568 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.11.10" + "version": "0.7.11.11" }, "servers": [ { @@ -876,6 +876,36 @@ } } }, + "/api/Account/is-email-valid": { + "get": { + "tags": [ + "Account" + ], + "summary": "Is the user's current email valid or not", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Admin/exists": { "get": { "tags": [