Polish before Release 2 (#3723)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-04-11 09:07:17 -06:00 committed by GitHub
parent 67d7d8467e
commit 4453482d93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 201 additions and 154 deletions

View File

@ -6,11 +6,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.12" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.12" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2"> <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -52,7 +52,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@ -70,11 +70,11 @@
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" /> <PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" /> <PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
@ -92,15 +92,15 @@
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" /> <PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.39.0" /> <PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.12" /> <PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
<PackageReference Include="System.Drawing.Common" Version="9.0.3" /> <PackageReference Include="System.Drawing.Common" Version="9.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.3" /> <PackageReference Include="VersOne.Epub" Version="3.3.3" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>

View File

@ -138,6 +138,12 @@ public class AccountController : BaseApiController
return BadRequest(usernameValidation); return BadRequest(usernameValidation);
} }
// If Email is empty, default to the username
if (string.IsNullOrEmpty(registerDto.Email))
{
registerDto.Email = registerDto.Username;
}
var user = new AppUserBuilder(registerDto.Username, registerDto.Email, var user = new AppUserBuilder(registerDto.Username, registerDto.Email,
await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
@ -352,10 +358,11 @@ public class AccountController : BaseApiController
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns>Returns just if the email was sent or server isn't reachable</returns> /// <returns>Returns just if the email was sent or server isn't reachable</returns>
[HttpPost("update/email")] [HttpPost("update/email")]
public async Task<ActionResult> UpdateEmail(UpdateEmailDto? dto) public async Task<ActionResult<InviteUserResponse>> UpdateEmail(UpdateEmailDto? dto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole))
return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
@ -364,12 +371,13 @@ public class AccountController : BaseApiController
// Validate this user's password // Validate this user's password
if (! await _userManager.CheckPasswordAsync(user, dto.Password)) if (! await _userManager.CheckPasswordAsync(user, dto.Password))
{ {
_logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); _logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
} }
// Validate no other users exist with this email // Validate no other users exist with this email
if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); if (user.Email!.Equals(dto.Email))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
// Check if email is used by another user // Check if email is used by another user
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
@ -386,8 +394,10 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token"));
} }
var isValidEmailAddress = _emailService.IsValidEmail(user.Email);
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress;
user.EmailConfirmed = !shouldEmailUser; user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token; user.ConfirmationToken = token;
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
@ -401,7 +411,8 @@ public class AccountController : BaseApiController
return Ok(new InviteUserResponse return Ok(new InviteUserResponse
{ {
EmailLink = string.Empty, EmailLink = string.Empty,
EmailSent = false EmailSent = false,
InvalidEmail = !isValidEmailAddress
}); });
} }
@ -409,7 +420,7 @@ public class AccountController : BaseApiController
// Send a confirmation email // Send a confirmation email
try try
{ {
if (!_emailService.IsValidEmail(user.Email)) if (!isValidEmailAddress)
{ {
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email); _logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
return Ok(new InviteUserResponse return Ok(new InviteUserResponse
@ -441,7 +452,8 @@ public class AccountController : BaseApiController
return Ok(new InviteUserResponse return Ok(new InviteUserResponse
{ {
EmailLink = string.Empty, EmailLink = string.Empty,
EmailSent = true EmailSent = true,
InvalidEmail = !isValidEmailAddress
}); });
} }
catch (Exception ex) catch (Exception ex)

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace API.DTOs; namespace API.DTOs;
#nullable enable
public class RegisterDto public class RegisterDto
{ {
@ -9,7 +10,7 @@ public class RegisterDto
/// <summary> /// <summary>
/// An email to register with. Optional. Provides Forgot Password functionality /// An email to register with. Optional. Provides Forgot Password functionality
/// </summary> /// </summary>
public string Email { get; init; } = default!; public string? Email { get; set; } = default!;
[Required] [Required]
[StringLength(256, MinimumLength = 6)] [StringLength(256, MinimumLength = 6)]
public string Password { get; set; } = default!; public string Password { get; set; } = default!;

View File

@ -510,7 +510,7 @@ public static class SeriesFilter
return queries.Aggregate((q1, q2) => q1.Intersect(q2)); return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.IsEmpty: case FilterComparison.IsEmpty:
return queryable.Where(s => s.Metadata.Tags == null || s.Metadata.Tags.Count == 0); return queryable.Where(s => s.Metadata.Tags.Count == 0);
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -707,7 +707,7 @@ public static class SeriesFilter
return queries.Aggregate((q1, q2) => q1.Intersect(q2)); return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.IsEmpty: case FilterComparison.IsEmpty:
return queryable.Where(s => collectionSeries.All(c => c != s.Id)); return queryable.Where(s => s.Collections.Count == 0);
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:

View File

@ -9,12 +9,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Cronos" Version="0.9.0" /> <PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="DotNet.Glob" Version="3.1.3" /> <PackageReference Include="DotNet.Glob" Version="3.1.3" />
<PackageReference Include="Flurl.Http" Version="4.0.2" /> <PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -39,7 +39,7 @@
"luxon": "^3.6.1", "luxon": "^3.6.1",
"ng-circle-progress": "^1.7.1", "ng-circle-progress": "^1.7.1",
"ng-lazyload-image": "^9.1.3", "ng-lazyload-image": "^9.1.3",
"ng-select2-component": "^17.2.3", "ng-select2-component": "^17.2.4",
"ngx-color-picker": "^19.0.0", "ngx-color-picker": "^19.0.0",
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
@ -7412,9 +7412,9 @@
} }
}, },
"node_modules/ng-select2-component": { "node_modules/ng-select2-component": {
"version": "17.2.3", "version": "17.2.4",
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.3.tgz", "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.4.tgz",
"integrity": "sha512-JNik7OWqya4ERuqlfnYiJHkaqyZtHqUhATIZ9yUxmadWWNIn8I3Lwa7qt0KtPpR01O9HJC0PtHXhvev88Cju2A==", "integrity": "sha512-pfRQg1gY1NsQkBNAYYeSYJjejKwz1z+9bKWor8/8toCNbvh9TYMOKpcz3FrNvhR6v/Hto/quddajaxjD81TOgg==",
"dependencies": { "dependencies": {
"ngx-infinite-scroll": ">=18.0.0 || >=19.0.0", "ngx-infinite-scroll": ">=18.0.0 || >=19.0.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"

View File

@ -47,7 +47,7 @@
"luxon": "^3.6.1", "luxon": "^3.6.1",
"ng-circle-progress": "^1.7.1", "ng-circle-progress": "^1.7.1",
"ng-lazyload-image": "^9.1.3", "ng-lazyload-image": "^9.1.3",
"ng-select2-component": "^17.2.3", "ng-select2-component": "^17.2.4",
"ngx-color-picker": "^19.0.0", "ngx-color-picker": "^19.0.0",
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",

View File

@ -1,22 +1,20 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Injectable } from '@angular/core'; import {DestroyRef, inject, Injectable} from '@angular/core';
import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators'; import {filter, map, switchMap, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences'; import {Preferences} from '../_models/preferences/preferences';
import { User } from '../_models/user'; import {User} from '../_models/user';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { EVENTS, MessageHubService } from './message-hub.service'; import {EVENTS, MessageHubService} from './message-hub.service';
import { ThemeService } from './theme.service'; import {ThemeService} from './theme.service';
import { InviteUserResponse } from '../_models/auth/invite-user-response'; import {InviteUserResponse} from '../_models/auth/invite-user-response';
import { UserUpdateEvent } from '../_models/events/user-update-event'; import {UserUpdateEvent} from '../_models/events/user-update-event';
import { AgeRating } from '../_models/metadata/age-rating'; import {AgeRating} from '../_models/metadata/age-rating';
import { AgeRestriction } from '../_models/metadata/age-restriction'; import {AgeRestriction} from '../_models/metadata/age-restriction';
import { TextResonse } from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Action} from "./action-factory.service"; import {Action} from "./action-factory.service";
import {CoverImageSize} from "../admin/_models/cover-image-size";
import {LicenseInfo} from "../_models/kavitaplus/license-info";
import {LicenseService} from "./license.service"; import {LicenseService} from "./license.service";
import {LocalizationService} from "./localization.service"; import {LocalizationService} from "./localization.service";
@ -132,7 +130,7 @@ export class AccountService {
} }
hasChangeAgeRestrictionRole(user: User) { hasChangeAgeRestrictionRole(user: User) {
return user && user.roles.includes(Role.ChangeRestriction); return user && !user.roles.includes(Role.Admin) && user.roles.includes(Role.ChangeRestriction);
} }
hasDownloadRole(user: User) { hasDownloadRole(user: User) {
@ -199,9 +197,9 @@ export class AccountService {
if (this.currentUser) { if (this.currentUser) {
// BUG: StopHubConnection has a promise in it, this needs to be async // BUG: StopHubConnection has a promise in it, this needs to be async
// But that really messes everything up // But that really messes everything up
this.messageHub.stopHubConnection();
this.messageHub.createHubConnection(this.currentUser);
if (!isSameUser) { if (!isSameUser) {
this.messageHub.stopHubConnection();
this.messageHub.createHubConnection(this.currentUser);
this.licenseService.hasValidLicense().subscribe(); this.licenseService.hasValidLicense().subscribe();
} }
this.startRefreshTokenTimer(); this.startRefreshTokenTimer();

View File

@ -17,12 +17,8 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<div class="input-group"> <input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text"
<input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text" [class.is-invalid]="formControl.invalid && !formControl.untouched" appEnterBlur>
[class.is-invalid]="formControl.invalid && !formControl.untouched" appEnterBlur>
<button type="button" class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
<button type="button" class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div>
@if (formControl.errors; as errors) { @if (formControl.errors; as errors) {
<div id="hostname-validations" class="invalid-feedback" style="display: inline-block"> <div id="hostname-validations" class="invalid-feedback" style="display: inline-block">
@ -69,7 +65,11 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="text" class="form-control" formControlName="host" id="settings-host" appEnterBlur /> <div class="input-group">
<input type="text" class="form-control" formControlName="host" id="settings-host" appEnterBlur />
<button type="button" class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
<button type="button" class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div>
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }

View File

@ -18,7 +18,7 @@
</div> </div>
<div class="col-md-4 col-10 mb-2"> <div class="col-md-4 col-10 mb-2">
@if (formGroup.get('comparison')?.value !== FilterComparison.IsEmpty) { @if (IsEmptySelected) {
@if (predicateType$ | async; as predicateType) { @if (predicateType$ | async; as predicateType) {
@switch (predicateType) { @switch (predicateType) {
@case (PredicateType.Text) { @case (PredicateType.Text) {

View File

@ -170,6 +170,10 @@ export class MetadataFilterRowComponent implements OnInit {
private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService); private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService);
private readonly ageRatingPipe = new AgeRatingPipe(); private readonly ageRatingPipe = new AgeRatingPipe();
get IsEmptySelected() {
return parseInt(this.formGroup.get('comparison')?.value + '', 10) !== FilterComparison.IsEmpty;
}
get UiLabel(): FilterRowUi | null { get UiLabel(): FilterRowUi | null {
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField; const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
@ -348,6 +352,7 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(''); this.formGroup.get('filterValue')?.patchValue('');
this.formGroup.get('comparison')?.patchValue(StringComparisons[0]); this.formGroup.get('comparison')?.patchValue(StringComparisons[0]);
} }
this.cdRef.markForCheck();
return; return;
} }
@ -363,10 +368,13 @@ export class MetadataFilterRowComponent implements OnInit {
this.validComparisons$.next([...new Set(comps)]); this.validComparisons$.next([...new Set(comps)]);
this.predicateType$.next(PredicateType.Number); this.predicateType$.next(PredicateType.Number);
if (this.loaded) { if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue(0); this.formGroup.get('filterValue')?.patchValue(0);
this.formGroup.get('comparison')?.patchValue(NumberComparisons[0]); this.formGroup.get('comparison')?.patchValue(NumberComparisons[0]);
} }
this.cdRef.markForCheck();
return; return;
} }
@ -383,6 +391,7 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(false); this.formGroup.get('filterValue')?.patchValue(false);
this.formGroup.get('comparison')?.patchValue(DateComparisons[0]); this.formGroup.get('comparison')?.patchValue(DateComparisons[0]);
} }
this.cdRef.markForCheck();
return; return;
} }
@ -400,6 +409,7 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(false); this.formGroup.get('filterValue')?.patchValue(false);
this.formGroup.get('comparison')?.patchValue(BooleanComparisons[0]); this.formGroup.get('comparison')?.patchValue(BooleanComparisons[0]);
} }
this.cdRef.markForCheck();
return; return;
} }
@ -421,15 +431,15 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(0); this.formGroup.get('filterValue')?.patchValue(0);
this.formGroup.get('comparison')?.patchValue(comps[0]); this.formGroup.get('comparison')?.patchValue(comps[0]);
} }
this.cdRef.markForCheck();
return; return;
} }
} }
onDateSelect(_: NgbDate) { onDateSelect(_: NgbDate) {
this.propagateFilterUpdate(); this.propagateFilterUpdate();
} }
updateIfDateFilled() { updateIfDateFilled() {
this.propagateFilterUpdate(); this.propagateFilterUpdate();
} }

View File

@ -8,11 +8,13 @@
<label for="username" class="form-label">{{t('username-label')}}</label> <label for="username" class="form-label">{{t('username-label')}}</label>
<input id="username" class="form-control custom-input" formControlName="username" type="text" autocomplete="username" <input id="username" class="form-control custom-input" formControlName="username" type="text" autocomplete="username"
[class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched" aria-describedby="username-validations"> [class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched" aria-describedby="username-validations">
<div id="username-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched"> @if (registerForm.dirty || registerForm.touched) {
<div *ngIf="registerForm.get('username')?.errors?.required"> <div id="username-validations" class="invalid-feedback">
{{t('required-field')}} @if (registerForm.get('username')?.errors?.required) {
<div>{{t('required-field')}}</div>
}
</div> </div>
</div> }
</div> </div>
<div class="mb-3 text-start"> <div class="mb-3 text-start">
@ -22,16 +24,18 @@
<span class="visually-hidden" id="email-help"> <span class="visually-hidden" id="email-help">
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container> <ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
</span> </span>
<input class="form-control custom-input" type="email" inputmode="email" id="email" autocomplete="email" formControlName="email" required aria-describedby="email-help" <input class="form-control custom-input" type="email" inputmode="email" id="email" autocomplete="email" formControlName="email" aria-describedby="email-help"
[class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched"> [class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
<div id="email-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched"> @if (registerForm.dirty || registerForm.touched) {
<div *ngIf="registerForm.get('email')?.errors?.required"> <div id="email-validations" class="invalid-feedback">
{{t('required-field')}} @if (registerForm.get('email')?.errors?.required) {
<div>{{t('required-field')}}</div>
} @else if (registerForm.get('email')?.errors?.email) {
<div>{{t('valid-email')}}</div>
}
</div> </div>
<div *ngIf="registerForm.get('email')?.errors?.email"> }
{{t('valid-email')}}
</div>
</div>
</div> </div>
<div class="mb-3 text-start"> <div class="mb-3 text-start">

View File

@ -1,13 +1,13 @@
import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import { take } from 'rxjs/operators'; import {take} from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service'; import {MemberService} from 'src/app/_services/member.service';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgTemplateOutlet } from '@angular/common'; import {NgTemplateOutlet} from '@angular/common';
import { SplashContainerComponent } from '../splash-container/splash-container.component'; import {SplashContainerComponent} from '../splash-container/splash-container.component';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {NavService} from "../../../_services/nav.service"; import {NavService} from "../../../_services/nav.service";
@ -15,25 +15,28 @@ import {NavService} from "../../../_services/nav.service";
* This is exclusively used to register the first user on the server and nothing else * This is exclusively used to register the first user on the server and nothing else
*/ */
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
templateUrl: './register.component.html', templateUrl: './register.component.html',
styleUrls: ['./register.component.scss'], styleUrls: ['./register.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, imports: [SplashContainerComponent, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective],
imports: [SplashContainerComponent, ReactiveFormsModule, NgIf, NgbTooltip, NgTemplateOutlet, TranslocoDirective] changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class RegisterComponent { export class RegisterComponent {
private readonly navService = inject(NavService);
private readonly router = inject(Router);
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
private readonly memberService = inject(MemberService);
registerForm: FormGroup = new FormGroup({ registerForm: FormGroup = new FormGroup({
email: new FormControl('', [Validators.required]),
username: new FormControl('', [Validators.required]), username: new FormControl('', [Validators.required]),
email: new FormControl('', []),
password: new FormControl('', [Validators.required, Validators.maxLength(256), password: new FormControl('', [Validators.required, Validators.maxLength(256),
Validators.minLength(6), Validators.pattern("^.{6,256}$")]), Validators.minLength(6), Validators.pattern("^.{6,256}$")]),
}); });
private readonly navService = inject(NavService); constructor() {
constructor(private router: Router, private accountService: AccountService,
private toastr: ToastrService, private memberService: MemberService) {
this.navService.hideNavBar(); this.navService.hideNavBar();
this.navService.hideSideNav(); this.navService.hideSideNav();

View File

@ -2,9 +2,16 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ContentChild, ElementRef, EventEmitter, HostListener, ContentChild,
ElementRef,
EventEmitter,
HostListener,
inject, inject,
Input, OnChanges, Output, SimpleChange, SimpleChanges, Input,
OnChanges,
Output,
SimpleChange,
SimpleChanges,
TemplateRef TemplateRef
} from '@angular/core'; } from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
@ -28,6 +35,7 @@ import {AbstractControl} from "@angular/forms";
export class SettingItemComponent implements OnChanges { export class SettingItemComponent implements OnChanges {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly elementRef = inject(ElementRef);
@Input({required:true}) title: string = ''; @Input({required:true}) title: string = '';
@Input() editLabel: string | undefined = undefined; @Input() editLabel: string | undefined = undefined;
@ -98,11 +106,10 @@ export class SettingItemComponent implements OnChanges {
if (!this.canEdit) return; if (!this.canEdit) return;
if (this.control != null && this.control.invalid) return; if (this.control != null && this.control.invalid) return;
console.log('isEditMode', this.isEditMode, 'currentValue', change.currentValue);
this.isEditMode = change.currentValue; this.isEditMode = change.currentValue;
//this.editMode.emit(this.isEditMode);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.focusInput();
} }
} }
@ -114,7 +121,27 @@ export class SettingItemComponent implements OnChanges {
this.isEditMode = !this.isEditMode; this.isEditMode = !this.isEditMode;
this.editMode.emit(this.isEditMode); this.editMode.emit(this.isEditMode);
this.focusInput();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
focusInput() {
if (this.isEditMode) {
setTimeout(() => {
const inputElem = this.findFirstInput();
if (inputElem) {
inputElem.focus();
}
}, 10);
}
}
private findFirstInput(): HTMLInputElement | null {
const nativeInputs = [...this.elementRef.nativeElement.querySelectorAll('input'), ...this.elementRef.nativeElement.querySelectorAll('select'), ...this.elementRef.nativeElement.querySelectorAll('textarea')];
if (nativeInputs.length === 0) return null;
return nativeInputs[0];
}
} }

View File

@ -239,6 +239,12 @@ export class SideNavComponent implements OnInit {
} }
async reorderDrop($event: CdkDragDrop<any, any, SideNavStream>) { async reorderDrop($event: CdkDragDrop<any, any, SideNavStream>) {
// Don't allow dropping on non SideNav items
const fixedSideNavItems = 3;
if ($event.currentIndex < fixedSideNavItems) {
return;
}
const stream = $event.item.data; const stream = $event.item.data;
// Offset the home, back, and customize button // Offset the home, back, and customize button
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({ this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read:'change-age-restriction'"> <ng-container *transloco="let t; read:'change-age-restriction'">
@if (user) { @if (user) {
<app-setting-item [title]="t('age-restriction-label')" [canEdit]="accountService.hasChangeAgeRestrictionRole(user) || accountService.hasAdminRole(user)"> <app-setting-item [title]="t('age-restriction-label')" [canEdit]="accountService.hasChangeAgeRestrictionRole(user)">
<ng-template #view> <ng-template #view>
<span class="col-12" [ngClass]="{'disabled': !accountService.hasChangeAgeRestrictionRole(user) && !accountService.hasAdminRole(user)}">{{user.ageRestriction.ageRating | ageRating }} <span class="col-12" [ngClass]="{'disabled': !accountService.hasChangeAgeRestrictionRole(user) && !accountService.hasAdminRole(user)}">{{user.ageRestriction.ageRating | ageRating }}
@if (user.ageRestriction.ageRating !== AgeRating.NotApplicable && user.ageRestriction.includeUnknowns) { @if (user.ageRestriction.ageRating !== AgeRating.NotApplicable && user.ageRestriction.includeUnknowns) {

View File

@ -1,7 +1,8 @@
<ng-container *transloco="let t; read:'change-email'"> <ng-container *transloco="let t; read:'change-email'">
<app-setting-item [title]="t('email-title')" [canEdit]="canEdit"> <app-setting-item [title]="t('email-title')" [canEdit]="canEdit" [isEditMode]="isEditMode" (editMode)="updateEditMode($event)">
<ng-template #extra> <ng-template #view>
<span>{{user?.email}}</span>
@if(emailConfirmed) { @if(emailConfirmed) {
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-confirmed')"></i> <i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-confirmed')"></i>
<span class="visually-hidden">{{t('email-confirmed')}}</span> <span class="visually-hidden">{{t('email-confirmed')}}</span>
@ -11,10 +12,6 @@
} }
</ng-template> </ng-template>
<ng-template #view>
<span>{{user?.email}}</span>
</ng-template>
<ng-template #edit> <ng-template #edit>
@if (errors.length > 0) { @if (errors.length > 0) {
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">

View File

@ -1,55 +1,58 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {shareReplay} from 'rxjs'; import {shareReplay} from 'rxjs';
import {User} from 'src/app/_models/user'; import {User} from 'src/app/_models/user';
import {AccountService} from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { ApiKeyComponent } from '../api-key/api-key.component'; import {ApiKeyComponent} from '../api-key/api-key.component';
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ScrobbleProviderNamePipe} from "../../_pipes/scrobble-provider-name.pipe";
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
@Component({ @Component({
selector: 'app-change-email', selector: 'app-change-email',
templateUrl: './change-email.component.html', templateUrl: './change-email.component.html',
styleUrls: ['./change-email.component.scss'], styleUrls: ['./change-email.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgbTooltip, ReactiveFormsModule, ApiKeyComponent, TranslocoDirective, SettingItemComponent] imports: [NgbTooltip, ReactiveFormsModule, ApiKeyComponent, TranslocoDirective, SettingItemComponent]
}) })
export class ChangeEmailComponent implements OnInit { export class ChangeEmailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly accountService = inject(AccountService);
form: FormGroup = new FormGroup({}); form: FormGroup = new FormGroup({});
user: User | undefined = undefined; user: User | undefined = undefined;
errors: string[] = []; errors: string[] = [];
isViewMode: boolean = true; isEditMode: boolean = false;
emailLink: string = ''; emailLink: string = '';
emailConfirmed: boolean = true; emailConfirmed: boolean = true;
hasValidEmail: boolean = true; hasValidEmail: boolean = true;
canEdit: boolean = false; canEdit: boolean = false;
public get email() { return this.form.get('email'); } protected get email() { return this.form.get('email'); }
makeLink: (val: string) => string = (val: string) => {return this.emailLink}; makeLink: (val: string) => string = (val: string) => {return this.emailLink};
constructor(public accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => {
this.user = user; this.user = user!;
this.canEdit = !this.accountService.hasReadOnlyRole(user!); this.canEdit = !this.accountService.hasReadOnlyRole(user!);
this.form.addControl('email', new FormControl(user?.email, [Validators.required, Validators.email])); this.form.addControl('email', new FormControl(user?.email, [Validators.required, Validators.email]));
this.form.addControl('password', new FormControl('', [Validators.required])); this.form.addControl('password', new FormControl('', [Validators.required]));
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.accountService.isEmailConfirmed().subscribe((confirmed) => { this.accountService.isEmailConfirmed().subscribe((confirmed) => {
this.emailConfirmed = confirmed; this.emailConfirmed = confirmed;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
this.accountService.isEmailValid().subscribe(isValid => { this.accountService.isEmailValid().subscribe(isValid => {
this.hasValidEmail = isValid; this.hasValidEmail = isValid;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -68,42 +71,31 @@ export class ChangeEmailComponent implements OnInit {
const model = this.form.value; const model = this.form.value;
this.errors = []; this.errors = [];
this.accountService.updateEmail(model.email, model.password).subscribe(updateEmailResponse => { this.accountService.updateEmail(model.email, model.password).subscribe(updateEmailResponse => {
if (updateEmailResponse.invalidEmail) { if (updateEmailResponse.invalidEmail) {
this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email})); this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email}));
} } else if (updateEmailResponse.emailSent) {
if (updateEmailResponse.emailSent) {
this.toastr.success(translate('toasts.email-sent-to')); this.toastr.success(translate('toasts.email-sent-to'));
} else { } else {
this.toastr.success(translate('toasts.change-email-no-email')); this.toastr.success(translate('toasts.change-email-no-email'));
this.accountService.refreshAccount().subscribe(user => {
this.user = user;
this.form.get('email')?.setValue(this.user?.email);
this.cdRef.markForCheck();
});
} }
this.isViewMode = true; this.accountService.refreshAccount().subscribe(user => {
this.resetForm(); this.user = user;
this.resetForm();
this.cdRef.markForCheck();
});
this.isEditMode = false;
this.cdRef.markForCheck();
}, err => { }, err => {
this.errors = err; this.errors = err;
this.cdRef.markForCheck();
}) })
} }
toggleViewMode() { updateEditMode(val: boolean) {
this.isViewMode = !this.isViewMode; this.isEditMode = val;
this.resetForm();
}
updateEditMode(mode: boolean) {
this.isViewMode = !mode;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.resetForm();
} }
} }

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read:'change-password'"> <ng-container *transloco="let t; read:'change-password'">
<app-setting-item [title]="t('password-label')" [canEdit]="canEdit"> <app-setting-item [title]="t('password-label')" [canEdit]="canEdit" [isEditMode]="isEditMode" (editMode)="updateEditMode($event)">
<ng-template #view> <ng-template #view>
<span class="col-12">***************</span> <span class="col-12">***************</span>
</ng-template> </ng-template>

View File

@ -7,16 +7,13 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import { map, Observable, of, shareReplay } from 'rxjs'; import {map, Observable, of, shareReplay} from 'rxjs';
import { User } from 'src/app/_models/user'; import {User} from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { AsyncPipe } from '@angular/common';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
@Component({ @Component({
@ -39,7 +36,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
observableHandles: Array<any> = []; observableHandles: Array<any> = [];
passwordsMatch = false; passwordsMatch = false;
resetPasswordErrors: string[] = []; resetPasswordErrors: string[] = [];
isViewMode: boolean = true; isEditMode: boolean = false;
canEdit: boolean = false; canEdit: boolean = false;
@ -90,7 +87,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => { this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
this.toastr.success(translate('toasts.password-updated')); this.toastr.success(translate('toasts.password-updated'));
this.resetPasswordForm(); this.resetPasswordForm();
this.isViewMode = true; this.isEditMode = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}, err => { }, err => {
this.resetPasswordErrors = err; this.resetPasswordErrors = err;
@ -99,7 +96,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
} }
updateEditMode(mode: boolean) { updateEditMode(mode: boolean) {
this.isViewMode = !mode; this.isEditMode = mode;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
} }