diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 1200c3097..2b86e6646 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -382,6 +382,41 @@ public class ReaderServiceTests Assert.Equal("2", actualChapter.Range); } + //[Fact] + public async Task GetNextChapterIdAsync_ShouldGetNextVolume_WhenUsingRanges() + { + // V1 -> V2 + await ResetDb(); + + var series = new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder("1-2") + .WithNumber(1) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3-4") + .WithNumber(2) + .WithChapter(new ChapterBuilder("21").Build()) + .WithChapter(new ChapterBuilder("22").Build()) + .Build()) + .Build(); + series.Library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build(); + + _context.Series.Add(series); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("3-4", actualChapter.Range); + } + [Fact] public async Task GetNextChapterIdAsync_ShouldGetNextVolume_OnlyFloats() { diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 69de1821b..de2cf0394 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -35,7 +35,13 @@ public static class PolicyConstants /// Used to give a user ability to Login to their account /// public const string LoginRole = "Login"; + /// + /// Restricts the ability to manage their account without an admin + /// + /// This is used explicitly for Demo Server. Not sure why it would be used in another fashion + public const string ReadOnlyRole = "Read Only"; + public static readonly ImmutableArray ValidRoles = - ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole); + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole); } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index c48546837..7cfa53f74 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -77,10 +77,11 @@ public class AccountController : BaseApiController [HttpPost("reset-password")] public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) { - _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); - var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName); if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system + _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = User.IsInRole(PolicyConstants.AdminRole); if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin)) @@ -319,6 +320,7 @@ public class AccountController : BaseApiController public async Task> ResetApiKey() { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()) ?? throw new KavitaUnauthenticatedUserException(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); user.ApiKey = HashUtil.ApiKey(); if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) @@ -345,7 +347,7 @@ public class AccountController : BaseApiController public async Task UpdateEmail(UpdateEmailDto? dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) 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)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); @@ -450,7 +452,7 @@ public class AccountController : BaseApiController if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (!await _accountService.HasChangeRestrictionRole(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRating; user.AgeRestrictionIncludeUnknowns = isAdmin || dto.IncludeUnknowns; @@ -898,7 +900,7 @@ public class AccountController : BaseApiController } var roles = await _userManager.GetRolesAsync(user); - if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) + if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole or PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(user.Id, "permission-denied")); if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) @@ -973,6 +975,7 @@ public class AccountController : BaseApiController /// /// /// + [Authorize("RequireAdminRole")] [HttpPost("resend-confirmation-email")] [EnableRateLimiting("Authentication")] public async Task> ResendConfirmationSendEmail([FromQuery] int userId) diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 1c384e7fe..e0339309b 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -128,7 +128,7 @@ public class SettingsController : BaseApiController /// Is the minimum information setup for Email to work /// /// - [Authorize] + [Authorize(Policy = "RequireAdminRole")] [HttpGet("is-email-setup")] public async Task> IsEmailSetup() { diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index f5239e708..2316e6a03 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -8,7 +8,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate { public int Id { get; set; } /// - /// A String representation of the volume number. Allows for floats. + /// A String representation of the volume number. Allows for floats. Can also include a range (1-2). /// /// For Books with Series_index, this will map to the Series Index. public required string Name { get; set; } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 45c88dd3f..71dc0f3b6 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -26,7 +26,7 @@ public interface IAccountService Task> ValidateEmail(string email); Task HasBookmarkPermission(AppUser? user); Task HasDownloadPermission(AppUser? user); - Task HasChangeRestrictionRole(AppUser? user); + Task CanChangeAgeRestriction(AppUser? user); } public class AccountService : IAccountService @@ -128,14 +128,15 @@ public class AccountService : IAccountService } /// - /// Does the user have Change Restriction permission or admin rights + /// Does the user have Change Restriction permission or admin rights and not Read Only /// /// /// - public async Task HasChangeRestrictionRole(AppUser? user) + public async Task CanChangeAgeRestriction(AppUser? user) { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 598539ab8..a0ed0e20a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -20,7 +20,8 @@ export enum Role { ChangePassword = 'Change Password', Bookmark = 'Bookmark', Download = 'Download', - ChangeRestriction = 'Change Restriction' + ChangeRestriction = 'Change Restriction', + ReadOnly = 'Read Only' } @Injectable({ @@ -80,6 +81,10 @@ export class AccountService { return user && user.roles.includes(Role.Bookmark); } + hasReadOnlyRole(user: User) { + return user && user.roles.includes(Role.ReadOnly); + } + getRoles() { return this.httpClient.get(this.baseUrl + 'account/roles'); } diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 94c79b1a6..6442addde 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectorRef, Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core'; import {NavigationStart, Router, RouterOutlet} from '@angular/router'; -import {map, shareReplay, take} from 'rxjs/operators'; +import {map, shareReplay, take, tap} from 'rxjs/operators'; import { AccountService } from './_services/account.service'; import { LibraryService } from './_services/library.service'; import { NavService } from './_services/nav.service'; @@ -67,8 +67,21 @@ export class AppComponent implements OnInit { }); + // Every hour, have the UI check for an update. People seriously stay out of date + // interval(60 * 60 * 1000) // 60 minutes in milliseconds + // .pipe( + // switchMap(() => this.accountService.currentUser$), + // filter(u => u !== undefined && this.accountService.hasAdminRole(u)), + // switchMap(_ => this.serverService.checkForUpdates()) + // ) + // .subscribe(); - this.transitionState$ = this.accountService.currentUser$.pipe(map((user) => { + + this.transitionState$ = this.accountService.currentUser$.pipe( + tap(user => { + + }), + map((user) => { if (!user) return false; return user.preferences.noTransitions; }), takeUntilDestroyed(this.destroyRef)); diff --git a/UI/Web/src/app/user-settings/api-key/api-key.component.ts b/UI/Web/src/app/user-settings/api-key/api-key.component.ts index 5d50325a2..8fdb1dc15 100644 --- a/UI/Web/src/app/user-settings/api-key/api-key.component.ts +++ b/UI/Web/src/app/user-settings/api-key/api-key.component.ts @@ -26,23 +26,27 @@ import {translate, TranslocoDirective} from "@ngneat/transloco"; }) export class ApiKeyComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly confirmService = inject(ConfirmService); + private readonly accountService = inject(AccountService); + private readonly toastr = inject(ToastrService); + private readonly clipboard = inject(Clipboard); + private readonly cdRef = inject(ChangeDetectorRef); + @Input() title: string = 'API Key'; @Input() showRefresh: boolean = true; @Input() transform: (val: string) => string = (val: string) => val; @Input() tooltipText: string = ''; @Input() hideData = true; @ViewChild('apiKey') inputElem!: ElementRef; - key: string = ''; - private readonly destroyRef = inject(DestroyRef); + key: string = ''; isDataHidden: boolean = this.hideData; get InputType() { return (this.hideData && this.isDataHidden) ? 'password' : 'text'; } - constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService, private clipboard: Clipboard, - private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { @@ -53,6 +57,8 @@ export class ApiKeyComponent implements OnInit { key = translate('api-key.no-key'); } + this.showRefresh = !this.accountService.hasReadOnlyRole(user!); + if (this.transform != undefined) { this.key = this.transform(key); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html index 442a8f397..f5ec6a669 100644 --- a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html +++ b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html @@ -5,7 +5,7 @@

{{t('age-restriction-label')}}

- +
diff --git a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts index d6aa88136..e24e9442b 100644 --- a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts +++ b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts @@ -50,7 +50,7 @@ export class ChangeAgeRestrictionComponent implements OnInit { }); this.hasChangeAgeRestrictionAbility = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay(), map(user => { - return user !== undefined && (!this.accountService.hasAdminRole(user) && this.accountService.hasChangeAgeRestrictionRole(user)); + return user !== undefined && !this.accountService.hasReadOnlyRole(user) && (!this.accountService.hasAdminRole(user) && this.accountService.hasChangeAgeRestrictionRole(user)); })); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.html b/UI/Web/src/app/user-settings/change-email/change-email.component.html index 565d7704d..4ca627824 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.html +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.html @@ -15,7 +15,7 @@
- +
diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.ts b/UI/Web/src/app/user-settings/change-email/change-email.component.ts index d8d0a6166..43ed80adc 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.ts +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.ts @@ -29,6 +29,7 @@ export class ChangeEmailComponent implements OnInit { emailLink: string = ''; emailConfirmed: boolean = true; hasValidEmail: boolean = true; + canEdit: boolean = false; public get email() { return this.form.get('email'); } @@ -38,8 +39,9 @@ export class ChangeEmailComponent implements OnInit { constructor(public accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { - this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay(), take(1)).subscribe(user => { + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => { this.user = user; + this.canEdit = !this.accountService.hasReadOnlyRole(user!); this.form.addControl('email', new FormControl(user?.email, [Validators.required, Validators.email])); this.form.addControl('password', new FormControl('', [Validators.required])); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/user-settings/change-password/change-password.component.html b/UI/Web/src/app/user-settings/change-password/change-password.component.html index e1f938e92..ab0034d70 100644 --- a/UI/Web/src/app/user-settings/change-password/change-password.component.html +++ b/UI/Web/src/app/user-settings/change-password/change-password.component.html @@ -5,7 +5,7 @@

{{t('password-label')}}

- +
diff --git a/UI/Web/src/app/user-settings/change-password/change-password.component.ts b/UI/Web/src/app/user-settings/change-password/change-password.component.ts index b0fba4c42..175b645a5 100644 --- a/UI/Web/src/app/user-settings/change-password/change-password.component.ts +++ b/UI/Web/src/app/user-settings/change-password/change-password.component.ts @@ -44,13 +44,13 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { ngOnInit(): void { - this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay(), take(1)).subscribe(user => { + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => { this.user = user; this.cdRef.markForCheck(); }); this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay(), map(user => { - return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user)); + return user !== undefined && !this.accountService.hasReadOnlyRole(user) && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user)); })); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 41875daf6..a306d7de8 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; +import {take, tap} from 'rxjs/operators'; import { Title } from '@angular/platform-browser'; import { readingDirections, @@ -129,7 +129,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { opdsUrl: string = ''; makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; }; hasActiveLicense = false; - + canEdit = true; @@ -142,6 +142,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); }); + this.settingsService.getOpdsEnabled().subscribe(res => { + this.opdsEnabled = res; + this.cdRef.markForCheck(); + }); + this.localizationService.getLocales().subscribe(res => { this.locales = res; this.cdRef.markForCheck(); @@ -149,7 +154,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { - this.accountService.hasValidLicense$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(res => { + this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { if (res) { this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling}); this.hasActiveLicense = true; @@ -169,10 +174,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { }); - this.settingsService.getOpdsEnabled().subscribe(res => { - this.opdsEnabled = res; - this.cdRef.markForCheck(); - }); + } ngOnInit(): void { diff --git a/openapi.json b/openapi.json index f75e079db..e4d121d2c 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.13.5" + "version": "0.7.13.6" }, "servers": [ { @@ -9560,37 +9560,6 @@ } } }, - "/api/Server/accessible": { - "get": { - "tags": [ - "Server" - ], - "summary": "Is this server accessible to the outside net", - "description": "If the instance has the HostName set, this will return true whether or not it is accessible externally", - "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "type": "boolean" - } - }, - "application/json": { - "schema": { - "type": "boolean" - } - }, - "text/json": { - "schema": { - "type": "boolean" - } - } - } - } - } - } - }, "/api/Server/jobs": { "get": { "tags": [ @@ -10116,6 +10085,36 @@ } } }, + "/api/Settings/test-email-url": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Sends a test email to see if email settings are hooked up correctly", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/EmailTestResultDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailTestResultDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/EmailTestResultDto" + } + } + } + } + } + } + }, "/api/Stats/user/{userId}/read": { "get": { "tags": [ @@ -14765,6 +14764,24 @@ }, "additionalProperties": false }, + "EmailTestResultDto": { + "type": "object", + "properties": { + "successful": { + "type": "boolean" + }, + "errorMessage": { + "type": "string", + "nullable": true + }, + "emailAddress": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Represents if Test Email Service URL was successful or not and if any error occured" + }, "ExternalRating": { "type": "object", "properties": { @@ -20222,7 +20239,7 @@ }, "name": { "type": "string", - "description": "A String representation of the volume number. Allows for floats.", + "description": "A String representation of the volume number. Allows for floats. Can also include a range (1-2).", "nullable": true }, "number": {