mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Random Bugs (#2531)
This commit is contained in:
parent
0c70e80420
commit
4e1c66331f
69
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
69
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal 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! 🚀
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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>()
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -546,7 +546,6 @@ public class BookService : IBookService
|
||||
ExtractSortTitle(metadataItem, epubBook, info);
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -261,7 +261,6 @@ public class Startup
|
||||
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
app.UseMiddleware<SecurityEventMiddleware>();
|
||||
app.UseMiddleware<CustomAuthHeaderMiddleware>();
|
||||
|
||||
|
||||
if (env.IsDevelopment())
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)';
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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'));
|
||||
}
|
||||
|
@ -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",
|
||||
|
32
openapi.json
32
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": [
|
||||
|
Loading…
x
Reference in New Issue
Block a user