Random Bugs (#2531)

This commit is contained in:
Joe Milazzo 2024-01-06 10:33:56 -06:00 committed by GitHub
parent 0c70e80420
commit 4e1c66331f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 232 additions and 178 deletions

69
.github/DISCUSSION_TEMPLATE/ideas.yml vendored Normal file
View File

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

View File

@ -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"));
}
/// <summary>
@ -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);
}
/// <summary>
/// Is the user's current email valid or not
/// </summary>
/// <returns></returns>
[HttpGet("is-email-valid")]
public async Task<ActionResult<bool>> 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));
}
}

View File

@ -10,4 +10,8 @@ public class InviteUserResponse
/// Was an email sent (ie is this server accessible)
/// </summary>
public bool EmailSent { get; set; } = default!;
/// <summary>
/// When a user has an invalid email and is attempting to perform a flow.
/// </summary>
public bool InvalidEmail { get; set; } = false;
}

View File

@ -1,14 +0,0 @@
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

@ -15,6 +15,7 @@ public class VolumeBuilder : IEntityBuilder<Volume>
_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<Chapter>()
};

View File

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

View File

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

View File

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

View File

@ -546,7 +546,6 @@ public class BookService : IBookService
ExtractSortTitle(metadataItem, epubBook, info);
}
break;
}
}

View File

@ -510,7 +510,6 @@ public class ProcessSeries : IProcessSeries
public void UpdateVolumes(Series series, IList<ParserInfo> 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<ParserInfo> parsedInfos, bool forceUpdate = false)

View File

@ -261,7 +261,6 @@ public class Startup
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<SecurityEventMiddleware>();
app.UseMiddleware<CustomAuthHeaderMiddleware>();
if (env.IsDevelopment())

View File

@ -7,4 +7,8 @@ export interface InviteUserResponse {
* If an email was sent to the invited user
*/
emailSent: boolean;
}
/**
* When a user has an invalid email and is attempting to perform a flow.
*/
invalidEmail: boolean;
}

View File

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

View File

@ -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<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, TextResonse);
isEmailValid() {
return this.httpClient.get<string>(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<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email, password});
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/update/email', {email, password});
}
updateAgeRestriction(ageRating: AgeRating, includeUnknowns: boolean) {

View File

@ -19,6 +19,9 @@
<div *ngIf="userForm.get('username')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="userForm.get('username')?.errors?.pattern">
{{t('username-pattern', {characters: allowedCharacters})}}
</div>
</div>
</div>
</div>

View File

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

View File

@ -39,8 +39,12 @@
<ng-container *ngIf="emailLink !== ''">
<h4>{{t('setup-user-title')}}</h4>
<p>{{t('setup-user-description')}}
</p>
<p>{{t('setup-user-description')}}</p>
@if (inviteError) {
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('email-not-sent')}}
</div>
}
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('setup-user-account-tooltip')" [hideData]="false" [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>

View File

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

View File

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

View File

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

View File

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

View File

@ -73,6 +73,6 @@
<div class="side-nav-overlay" (click)="toggleNavBar()" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async)}"></div>
<div class="bottom" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false || (accountService.hasValidLicense$ | async) === true}">
<app-side-nav-item *ngIf="(accountService.hasValidLicense$ | async) === false" [ngClass]="'donate'" icon="fa-heart" [title]="t('donate')" link="https://opencollective.com/kavita" [external]="true"></app-side-nav-item>
<app-side-nav-item *ngIf="(accountService.hasValidLicense$ | async) === false" [ngClass]="'donate'" icon="fa-heart" [ngbTooltip]="t('donate-tooltip')" link="https://wiki.kavitareader.com/en/faq#q-i-want-to-donate-what-are-my-options" [external]="true"></app-side-nav-item>
</div>
</ng-container>

View File

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

View File

@ -29,14 +29,22 @@
<div *ngFor="let error of errors">{{error}}</div>
</div>
<form [formGroup]="form">
@if(!hasValidEmail) {
<div class="alert alert-warning" role="alert">
{{t('has-invalid-email')}}
</div>
}
<div class="mb-3">
<label for="email" class="form-label visually-hidden">{{t('email-label')}}</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 id="email-validations" class="invalid-feedback" *ngIf="form.get('email')?.errors">
<div *ngIf="form.get('email')?.errors?.required">
{{t('required-field')}}
</div>
<div *ngIf="form.get('email')?.errors?.email">
{{t('valid-email')}}
</div>
</div>
</div>

View File

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

View File

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

View File

@ -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": [