mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Restricted Profiles (#1581)
* Added ReadingList age rating from all series and started on some unit tests for the new flows. * Wrote more unit tests for Reading Lists * Added ability to restrict user accounts to a given age rating via admin edit user modal and invite user. This commit contains all basic code, but no query modifications. * When updating a reading list's title via UI, explicitly check if there is an existing RL with the same title. * Refactored Reading List calculation to work properly in the flows it's invoked from. * Cleaned up an unused method * Promoted Collections no longer show tags where a Series exists within them that is above the user's age rating. * Collection search now respects age restrictions * Series Detail page now checks if the user has explicit access (as a user might bypass with direct url access) * Hooked up age restriction for dashboard activity streams. * Refactored some methods from Series Controller and Library Controller to a new Search Controller to keep things organized * Updated Search to respect age restrictions * Refactored all the Age Restriction queries to extensions * Related Series no longer show up if they are out of the age restriction * Fixed a bad mapping for the update age restriction api * Fixed a UI state change after updating age restriction * Fixed unit test * Added a migration for reading lists * Code cleanup
This commit is contained in:
parent
0ad1638ec0
commit
442af965c6
@ -4,10 +4,13 @@ using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -79,9 +82,10 @@ public class ReadingListServiceTests
|
||||
|
||||
private async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_context.AppUser.RemoveRange(_context.AppUser);
|
||||
_context.Series.RemoveRange(_context.Series);
|
||||
_context.ReadingList.RemoveRange(_context.ReadingList);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private static MockFileSystem CreateFileSystem()
|
||||
@ -99,11 +103,373 @@ public class ReadingListServiceTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateReadingListItemPosition
|
||||
|
||||
#region RemoveFullyReadItems
|
||||
[Fact]
|
||||
public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift()
|
||||
{
|
||||
await ResetDb();
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
Series = new List<Series>()
|
||||
{
|
||||
new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Name = "0",
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Number = "1",
|
||||
AgeRating = AgeRating.Everyone,
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "2",
|
||||
AgeRating = AgeRating.X18Plus
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "3",
|
||||
AgeRating = AgeRating.X18Plus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
|
||||
var readingList = new ReadingList();
|
||||
user.ReadingLists = new List<ReadingList>()
|
||||
{
|
||||
readingList
|
||||
};
|
||||
|
||||
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2, 3}, readingList);
|
||||
await _unitOfWork.CommitAsync();
|
||||
Assert.Equal(3, readingList.Items.Count);
|
||||
|
||||
await _readingListService.UpdateReadingListItemPosition(new UpdateReadingListPosition()
|
||||
{
|
||||
FromPosition = 2, ToPosition = 0, ReadingListId = 1, ReadingListItemId = 3
|
||||
});
|
||||
|
||||
|
||||
Assert.Equal(3, readingList.Items.Count);
|
||||
Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order);
|
||||
Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order);
|
||||
Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order);
|
||||
}
|
||||
|
||||
// TODO: Implement all methods here
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteReadingListItem
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteReadingListItem_DeleteFirstItem_SecondShouldBecomeFirst()
|
||||
{
|
||||
await ResetDb();
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
Series = new List<Series>()
|
||||
{
|
||||
new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Name = "0",
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Number = "1",
|
||||
AgeRating = AgeRating.Everyone
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "2",
|
||||
AgeRating = AgeRating.X18Plus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
|
||||
var readingList = new ReadingList();
|
||||
user.ReadingLists = new List<ReadingList>()
|
||||
{
|
||||
readingList
|
||||
};
|
||||
|
||||
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
|
||||
await _unitOfWork.CommitAsync();
|
||||
Assert.Equal(2, readingList.Items.Count);
|
||||
|
||||
await _readingListService.DeleteReadingListItem(new UpdateReadingListPosition()
|
||||
{
|
||||
ReadingListId = 1, ReadingListItemId = 1
|
||||
});
|
||||
|
||||
Assert.Equal(1, readingList.Items.Count);
|
||||
Assert.Equal(2, readingList.Items.First().ChapterId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveFullyReadItems
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFullyReadItems_RemovesAllFullyReadItems()
|
||||
{
|
||||
await ResetDb();
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
Series = new List<Series>()
|
||||
{
|
||||
new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Name = "0",
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Number = "1",
|
||||
AgeRating = AgeRating.Everyone,
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "2",
|
||||
AgeRating = AgeRating.X18Plus,
|
||||
Pages = 1
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "3",
|
||||
AgeRating = AgeRating.X18Plus,
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists | AppUserIncludes.Progress);
|
||||
var readingList = new ReadingList();
|
||||
user.ReadingLists = new List<ReadingList>()
|
||||
{
|
||||
readingList
|
||||
};
|
||||
|
||||
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2, 3}, readingList);
|
||||
await _unitOfWork.CommitAsync();
|
||||
Assert.Equal(3, readingList.Items.Count);
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
|
||||
Substitute.For<IEventHub>());
|
||||
// Mark 2 as fully read
|
||||
await readerService.MarkChaptersAsRead(user, 1,
|
||||
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(new List<int>() {2}));
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await _readingListService.RemoveFullyReadItems(1, user);
|
||||
|
||||
|
||||
Assert.Equal(2, readingList.Items.Count);
|
||||
Assert.DoesNotContain(readingList.Items, i => i.Id == 2);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region CalculateAgeRating
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateAgeRating_ShouldUpdateToUnknown_IfNoneSet()
|
||||
{
|
||||
await ResetDb();
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
Series = new List<Series>()
|
||||
{
|
||||
new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Name = "0",
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Number = "1",
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "2",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
|
||||
var readingList = new ReadingList();
|
||||
user.ReadingLists = new List<ReadingList>()
|
||||
{
|
||||
readingList
|
||||
};
|
||||
|
||||
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
|
||||
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await _readingListService.CalculateReadingListAgeRating(readingList);
|
||||
Assert.Equal(AgeRating.Unknown, readingList.AgeRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateAgeRating_ShouldUpdateToMax()
|
||||
{
|
||||
await ResetDb();
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
new Library()
|
||||
{
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
Series = new List<Series>()
|
||||
{
|
||||
new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Name = "0",
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Number = "1",
|
||||
},
|
||||
new Chapter()
|
||||
{
|
||||
Number = "2",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
|
||||
var readingList = new ReadingList();
|
||||
user.ReadingLists = new List<ReadingList>()
|
||||
{
|
||||
readingList
|
||||
};
|
||||
|
||||
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2}, readingList);
|
||||
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await _readingListService.CalculateReadingListAgeRating(readingList);
|
||||
Assert.Equal(AgeRating.Unknown, readingList.AgeRating);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
@ -27,7 +27,11 @@ public static class PolicyConstants
|
||||
/// Used to give a user ability to bookmark files on the server
|
||||
/// </summary>
|
||||
public const string BookmarkRole = "Bookmark";
|
||||
/// <summary>
|
||||
/// Used to give a user ability to Change Restrictions on their account
|
||||
/// </summary>
|
||||
public const string ChangeRestrictionRole = "Change Restriction";
|
||||
|
||||
public static readonly ImmutableArray<string> ValidRoles =
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole);
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole);
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ using API.DTOs.Account;
|
||||
using API.DTOs.Email;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
@ -358,6 +357,34 @@ public class AccountController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("update/age-restriction")]
|
||||
public async Task<ActionResult> UpdateAgeRestriction(UpdateAgeRestrictionDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized("You do not have permission");
|
||||
if (dto == null) return BadRequest("Invalid payload");
|
||||
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRestriction;
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok();
|
||||
try
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error updating the age restriction");
|
||||
return BadRequest("There was an error updating the age restriction");
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
|
||||
/// </summary>
|
||||
@ -428,6 +455,9 @@ public class AccountController : BaseApiController
|
||||
lib.AppUsers.Add(user);
|
||||
}
|
||||
|
||||
user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction;
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
|
||||
@ -540,6 +570,8 @@ public class AccountController : BaseApiController
|
||||
lib.AppUsers.Add(user);
|
||||
}
|
||||
|
||||
user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction;
|
||||
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
|
@ -41,7 +41,8 @@ public class CollectionController : BaseApiController
|
||||
{
|
||||
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
||||
}
|
||||
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
|
||||
|
||||
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -56,9 +57,10 @@ public class CollectionController : BaseApiController
|
||||
{
|
||||
queryString ??= "";
|
||||
queryString = queryString.Replace(@"%", string.Empty);
|
||||
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
||||
if (queryString.Length == 0) return await GetAllTags();
|
||||
|
||||
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -319,23 +319,6 @@ public class LibraryController : BaseApiController
|
||||
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
|
||||
{
|
||||
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
// Get libraries user has access to
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
|
||||
|
||||
return Ok(series);
|
||||
}
|
||||
|
||||
[HttpGet("type")]
|
||||
public async Task<ActionResult<LibraryType>> GetLibraryType(int libraryId)
|
||||
|
@ -76,7 +76,9 @@ public class MetadataController : BaseApiController
|
||||
/// Fetches all age ratings from the instance
|
||||
/// </summary>
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
|
||||
[HttpGet("age-ratings")]
|
||||
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
||||
{
|
||||
@ -90,14 +92,16 @@ public class MetadataController : BaseApiController
|
||||
{
|
||||
Title = t.ToDescription(),
|
||||
Value = t
|
||||
}));
|
||||
}).Where(r => r.Value > AgeRating.NotApplicable));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all publication status' from the instance
|
||||
/// </summary>
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
|
||||
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
|
||||
[HttpGet("publication-status")]
|
||||
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
|
||||
{
|
||||
|
@ -196,8 +196,8 @@ public class OpdsController : BaseApiController
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
IList<CollectionTagDto> tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList()
|
||||
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList();
|
||||
IEnumerable<CollectionTagDto> tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
|
||||
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
|
||||
|
||||
|
||||
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
|
||||
@ -239,7 +239,7 @@ public class OpdsController : BaseApiController
|
||||
}
|
||||
else
|
||||
{
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
|
||||
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
|
||||
}
|
||||
|
||||
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
|
||||
|
@ -45,11 +45,11 @@ public class ReadingListController : BaseApiController
|
||||
/// <summary>
|
||||
/// Returns reading lists (paginated) for a given user.
|
||||
/// </summary>
|
||||
/// <param name="includePromoted">Defaults to true</param>
|
||||
/// <param name="includePromoted">Include Promoted Reading Lists along with user's Reading Lists. Defaults to true</param>
|
||||
/// <param name="userParams">Pagination parameters</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("lists")]
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, bool includePromoted = true)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
|
||||
@ -217,9 +217,15 @@ public class ReadingListController : BaseApiController
|
||||
return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
|
||||
}
|
||||
|
||||
dto.Title = dto.Title.Trim();
|
||||
if (!string.IsNullOrEmpty(dto.Title))
|
||||
{
|
||||
readingList.Title = dto.Title; // Should I check if this is unique?
|
||||
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
|
||||
if (hasExisting)
|
||||
{
|
||||
return BadRequest("A list of this name already exists");
|
||||
}
|
||||
readingList.Title = dto.Title;
|
||||
readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(dto.Title))
|
||||
|
67
API/Controllers/SearchController.cs
Normal file
67
API/Controllers/SearchController.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Search;
|
||||
using API.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for the Search interface from the UI
|
||||
/// </summary>
|
||||
public class SearchController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public SearchController(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="mangaFileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-mangafile")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-chapter")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
|
||||
{
|
||||
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
// Get libraries user has access to
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
|
||||
|
||||
return Ok(series);
|
||||
}
|
||||
}
|
@ -363,10 +363,13 @@ public class SeriesController : BaseApiController
|
||||
/// </summary>
|
||||
/// <param name="ageRating"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>This is cached for an hour</remarks>
|
||||
[ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"ageRating"})]
|
||||
[HttpGet("age-rating")]
|
||||
public ActionResult<string> GetAgeRating(int ageRating)
|
||||
{
|
||||
var val = (AgeRating) ageRating;
|
||||
if (val == AgeRating.NotApplicable) return "No Restriction";
|
||||
|
||||
return Ok(val.ToDescription());
|
||||
}
|
||||
@ -385,31 +388,7 @@ public class SeriesController : BaseApiController
|
||||
return await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="mangaFileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-mangafile")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-chapter")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the related series for a given series
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
@ -16,4 +17,8 @@ public class InviteUserDto
|
||||
/// A list of libraries to grant access to
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
/// <summary>
|
||||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; }
|
||||
}
|
||||
|
10
API/DTOs/Account/UpdateAgeRestrictionDto.cs
Normal file
10
API/DTOs/Account/UpdateAgeRestrictionDto.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
public class UpdateAgeRestrictionDto
|
||||
{
|
||||
[Required]
|
||||
public AgeRating AgeRestriction { get; set; }
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using API.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
|
||||
|
||||
namespace API.DTOs.Account;
|
||||
|
||||
@ -13,5 +16,9 @@ public record UpdateUserDto
|
||||
/// A list of libraries to grant access to
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
/// <summary>
|
||||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; init; }
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
@ -11,6 +12,11 @@ public class MemberDto
|
||||
public int Id { get; init; }
|
||||
public string Username { get; init; }
|
||||
public string Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum age rating a user has access to. -1 if not applicable
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; init; } = AgeRating.NotApplicable;
|
||||
public DateTime Created { get; init; }
|
||||
public DateTime LastActive { get; init; }
|
||||
public IEnumerable<LibraryDto> Libraries { get; init; }
|
||||
|
@ -1,4 +1,6 @@
|
||||
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public class UserDto
|
||||
@ -9,4 +11,8 @@ public class UserDto
|
||||
public string RefreshToken { get; set; }
|
||||
public string ApiKey { get; init; }
|
||||
public UserPreferencesDto Preferences { get; set; }
|
||||
/// <summary>
|
||||
/// The highest age rating the user has access to. Not applicable for admins
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
|
||||
}
|
||||
|
36
API/Data/MigrateChangeRestrictionRoles.cs
Normal file
36
API/Data/MigrateChangeRestrictionRoles.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data;
|
||||
|
||||
/// <summary>
|
||||
/// New role introduced in v0.6. Adds the role to all users.
|
||||
/// </summary>
|
||||
public static class MigrateChangeRestrictionRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Will not run if any users have the <see cref="PolicyConstants.ChangeRestrictionRole"/> role already
|
||||
/// </summary>
|
||||
/// <param name="unitOfWork"></param>
|
||||
/// <param name="userManager"></param>
|
||||
/// <param name="logger"></param>
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> userManager, ILogger<Program> logger)
|
||||
{
|
||||
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangeRestrictionRole);
|
||||
if (usersWithRole.Count != 0) return;
|
||||
|
||||
logger.LogCritical("Running MigrateChangeRestrictionRoles migration");
|
||||
|
||||
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
|
||||
foreach (var user in allUsers)
|
||||
{
|
||||
await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole);
|
||||
await userManager.AddToRoleAsync(user, PolicyConstants.ChangeRestrictionRole);
|
||||
}
|
||||
|
||||
logger.LogInformation("MigrateChangeRestrictionRoles migration complete");
|
||||
}
|
||||
}
|
41
API/Data/MigrateReadingListAgeRating.cs
Normal file
41
API/Data/MigrateReadingListAgeRating.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace API.Data;
|
||||
|
||||
/// <summary>
|
||||
/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists
|
||||
/// </summary>
|
||||
public static class MigrateReadingListAgeRating
|
||||
{
|
||||
/// <summary>
|
||||
/// Will not run if any above v0.5.6.24 or v0.6.0
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
/// <param name="readingListService"></param>
|
||||
/// <param name="logger"></param>
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext context, IReadingListService readingListService, ILogger<Program> logger)
|
||||
{
|
||||
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
if (Version.Parse(settings.InstallVersion) > new Version(0, 5, 6, 24))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("MigrateReadingListAgeRating migration starting");
|
||||
var readingLists = await context.ReadingList.Include(r => r.Items).ToListAsync();
|
||||
foreach (var readingList in readingLists)
|
||||
{
|
||||
await readingListService.CalculateReadingListAgeRating(readingList);
|
||||
context.ReadingList.Update(readingList);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("MigrateReadingListAgeRating migration complete");
|
||||
}
|
||||
}
|
1667
API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs
generated
Normal file
1667
API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
API/Data/Migrations/20221009172653_ReadingListAgeRating.cs
Normal file
26
API/Data/Migrations/20221009172653_ReadingListAgeRating.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class ReadingListAgeRating : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AgeRating",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRating",
|
||||
table: "ReadingList");
|
||||
}
|
||||
}
|
||||
}
|
1670
API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs
generated
Normal file
1670
API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
API/Data/Migrations/20221009211237_UserAgeRating.cs
Normal file
27
API/Data/Migrations/20221009211237_UserAgeRating.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using API.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class UserAgeRating : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AgeRestriction",
|
||||
table: "AspNetUsers",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: AgeRating.NotApplicable);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRestriction",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
@ -53,6 +53,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AgeRestriction")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -736,6 +739,9 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -15,9 +16,9 @@ public interface ICollectionTagRepository
|
||||
void Add(CollectionTag tag);
|
||||
void Remove(CollectionTag tag);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery);
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
|
||||
Task<string> GetCoverImageAsync(int collectionTagId);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId);
|
||||
Task<CollectionTag> GetTagAsync(int tagId);
|
||||
Task<CollectionTag> GetFullTagAsync(int tagId);
|
||||
void Update(CollectionTag tag);
|
||||
@ -85,6 +86,7 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||
{
|
||||
|
||||
return await _context.CollectionTag
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
@ -92,10 +94,12 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync()
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId)
|
||||
{
|
||||
var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
|
||||
return await _context.CollectionTag
|
||||
.Where(c => c.Promoted)
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
@ -118,11 +122,13 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery)
|
||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId)
|
||||
{
|
||||
var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
|
||||
return await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
|
@ -23,6 +23,7 @@ public interface IReadingListRepository
|
||||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
|
||||
bool includePromoted);
|
||||
void Remove(ReadingListItem item);
|
||||
void Add(ReadingList list);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
Task<int> Count();
|
||||
@ -46,6 +47,11 @@ public class ReadingListRepository : IReadingListRepository
|
||||
_context.Entry(list).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Add(ReadingList list)
|
||||
{
|
||||
_context.Add(list);
|
||||
}
|
||||
|
||||
public async Task<int> Count()
|
||||
{
|
||||
return await _context.ReadingList.CountAsync();
|
||||
@ -82,8 +88,10 @@ public class ReadingListRepository : IReadingListRepository
|
||||
|
||||
public async Task<PagedList<ReadingListDto>> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams)
|
||||
{
|
||||
var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
|
||||
var query = _context.ReadingList
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.Where(l => l.AgeRating >= userAgeRating)
|
||||
.OrderBy(l => l.LastModified)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
@ -97,7 +105,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.Where(l => l.Items.Any(i => i.SeriesId == seriesId))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(l => l.LastModified)
|
||||
.OrderBy(l => l.Title)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
|
||||
|
@ -51,6 +51,7 @@ internal class RecentlyAddedSeries
|
||||
public string ChapterTitle { get; init; }
|
||||
public bool IsSpecial { get; init; }
|
||||
public int VolumeNumber { get; init; }
|
||||
public AgeRating AgeRating { get; init; }
|
||||
}
|
||||
|
||||
public interface ISeriesRepository
|
||||
@ -118,11 +119,11 @@ public interface ISeriesRepository
|
||||
Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
|
||||
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
|
||||
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
|
||||
Task<int> GetSeriesIdByFolder(string folder);
|
||||
Task<Series> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
||||
Task<Series> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
||||
Task<List<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
||||
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
|
||||
}
|
||||
|
||||
public class SeriesRepository : ISeriesRepository
|
||||
@ -307,9 +308,11 @@ public class SeriesRepository : ISeriesRepository
|
||||
const int maxRecords = 15;
|
||||
var result = new SearchResultGroupDto();
|
||||
var searchQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(searchQuery);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
var seriesIds = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
@ -333,6 +336,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")
|
||||
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.Include(s => s.Library)
|
||||
.OrderBy(s => s.SortName)
|
||||
.AsNoTracking()
|
||||
@ -341,19 +345,20 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
|
||||
|
||||
result.ReadingLists = await _context.ReadingList
|
||||
.Where(rl => rl.AppUserId == userId || rl.Promoted)
|
||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
result.Collections = await _context.CollectionTag
|
||||
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQueryNormalized}%"))
|
||||
.Where(s => s.Promoted || isAdmin)
|
||||
.Where(c => EF.Functions.Like(c.Title, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))
|
||||
.Where(c => c.Promoted || isAdmin)
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.OrderBy(s => s.Title)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
@ -392,7 +397,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ToListAsync();
|
||||
|
||||
var fileIds = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => seriesIds.Contains(s.Id))
|
||||
.AsSplitQuery()
|
||||
.SelectMany(s => s.Volumes)
|
||||
.SelectMany(v => v.Chapters)
|
||||
@ -735,6 +740,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
|
||||
{
|
||||
var userLibraries = await GetUserLibraries(libraryId, userId);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
||||
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
||||
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
|
||||
@ -759,8 +766,13 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(s => !hasSeriesNameFilter ||
|
||||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|
||||
.AsNoTracking();
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"));
|
||||
if (userRating != AgeRating.NotApplicable)
|
||||
{
|
||||
query = query.RestrictAgainstAgeRestriction(userRating);
|
||||
}
|
||||
|
||||
query = query.AsNoTracking();
|
||||
|
||||
// If no sort options, default to using SortName
|
||||
filter.SortOptions ??= new SortOptions()
|
||||
@ -1033,7 +1045,10 @@ public class SeriesRepository : ISeriesRepository
|
||||
{
|
||||
var seriesMap = new Dictionary<string, GroupedSeriesDto>();
|
||||
var index = 0;
|
||||
foreach (var item in await GetRecentlyAddedChaptersQuery(userId))
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
var items = (await GetRecentlyAddedChaptersQuery(userId));
|
||||
foreach (var item in items.Where(c => c.AgeRating <= userRating))
|
||||
{
|
||||
if (seriesMap.Keys.Count == pageSize) break;
|
||||
|
||||
@ -1061,11 +1076,19 @@ public class SeriesRepository : ISeriesRepository
|
||||
return seriesMap.Values.AsEnumerable();
|
||||
}
|
||||
|
||||
private async Task<AgeRating> GetUserAgeRestriction(int userId)
|
||||
{
|
||||
return (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind)
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
var usersSeriesIds = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.Select(s => s.Id);
|
||||
|
||||
var targetSeries = _context.SeriesRelation
|
||||
@ -1078,6 +1101,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
return await _context.Series
|
||||
.Where(s => targetSeries.Contains(s.Id))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
@ -1128,6 +1152,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
public async Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId)
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
return await _context.MangaFile
|
||||
.Where(m => m.Id == mangaFileId)
|
||||
.AsSplitQuery()
|
||||
@ -1135,6 +1161,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Select(c => c.Volume)
|
||||
.Select(v => v.Series)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
@ -1142,31 +1169,18 @@ public class SeriesRepository : ISeriesRepository
|
||||
public async Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId)
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
return await _context.Chapter
|
||||
.Where(m => m.Id == chapterId)
|
||||
.AsSplitQuery()
|
||||
.Select(c => c.Volume)
|
||||
.Select(v => v.Series)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a folder path return a Series with the <see cref="Series.FolderPath"/> that matches.
|
||||
/// </summary>
|
||||
/// <remarks>This will apply normalization on the path.</remarks>
|
||||
/// <param name="folder"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> GetSeriesIdByFolder(string folder)
|
||||
{
|
||||
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
|
||||
var series = await _context.Series
|
||||
.Where(s => s.FolderPath.Equals(normalized))
|
||||
.SingleOrDefaultAsync();
|
||||
return series?.Id ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a Series by Folder path. Null if not found.
|
||||
/// </summary>
|
||||
@ -1368,21 +1382,22 @@ public class SeriesRepository : ISeriesRepository
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId);
|
||||
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
|
||||
var userRating = await GetUserAgeRestriction(userId);
|
||||
|
||||
return new RelatedSeriesDto()
|
||||
{
|
||||
SourceSeriesId = seriesId,
|
||||
Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation),
|
||||
Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character),
|
||||
Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel),
|
||||
Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel),
|
||||
Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains),
|
||||
SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory),
|
||||
SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff),
|
||||
Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other),
|
||||
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting),
|
||||
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion),
|
||||
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi),
|
||||
Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation, userRating),
|
||||
Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character, userRating),
|
||||
Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel, userRating),
|
||||
Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel, userRating),
|
||||
Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains, userRating),
|
||||
SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory, userRating),
|
||||
SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff, userRating),
|
||||
Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other, userRating),
|
||||
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating),
|
||||
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating),
|
||||
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating),
|
||||
Parent = await _context.Series
|
||||
.SelectMany(s =>
|
||||
s.RelationOf.Where(r => r.TargetSeriesId == seriesId
|
||||
@ -1390,6 +1405,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
&& r.RelationKind != RelationKind.Prequel
|
||||
&& r.RelationKind != RelationKind.Sequel)
|
||||
.Select(sr => sr.Series))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
@ -1404,11 +1420,12 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Select(s => s.Id);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind)
|
||||
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind, AgeRating userRating)
|
||||
{
|
||||
return await _context.Series.SelectMany(s =>
|
||||
s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId))
|
||||
.Select(sr => sr.TargetSeries))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
@ -1417,16 +1434,15 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
|
||||
{
|
||||
var libraries = await _context.AppUser
|
||||
var libraryIds = await _context.AppUser
|
||||
.Where(u => u.Id == userId)
|
||||
.SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type}))
|
||||
.Select(l => l.LibraryId)
|
||||
.ToListAsync();
|
||||
var libraryIds = libraries.Select(l => l.LibraryId).ToList();
|
||||
|
||||
var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12);
|
||||
var ret = _context.Chapter
|
||||
.Where(c => c.Created >= withinLastWeek)
|
||||
.AsNoTracking()
|
||||
return _context.Chapter
|
||||
.Where(c => c.Created >= withinLastWeek).AsNoTracking()
|
||||
.Include(c => c.Volume)
|
||||
.ThenInclude(v => v.Series)
|
||||
.ThenInclude(s => s.Library)
|
||||
@ -1445,12 +1461,12 @@ public class SeriesRepository : ISeriesRepository
|
||||
ChapterRange = c.Range,
|
||||
IsSpecial = c.IsSpecial,
|
||||
VolumeNumber = c.Volume.Number,
|
||||
ChapterTitle = c.Title
|
||||
ChapterTitle = c.Title,
|
||||
AgeRating = c.Volume.Series.Metadata.AgeRating
|
||||
})
|
||||
.AsSplitQuery()
|
||||
.Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
|
||||
.AsEnumerable();
|
||||
return ret;
|
||||
}
|
||||
|
||||
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
|
||||
@ -1503,6 +1519,21 @@ public class SeriesRepository : ISeriesRepository
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the highest Age Rating for a list of Series
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => seriesIds.Contains(s.Id))
|
||||
.Include(s => s.Metadata)
|
||||
.Select(s => s.Metadata.AgeRating)
|
||||
.OrderBy(s => s)
|
||||
.LastOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static IQueryable<Series> AddIncludesToQuery(IQueryable<Series> query, SeriesIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Library))
|
||||
|
@ -396,6 +396,7 @@ public class UserRepository : IUserRepository
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
AgeRestriction = u.AgeRestriction,
|
||||
Libraries = u.Libraries.Select(l => new LibraryDto
|
||||
{
|
||||
Name = l.Name,
|
||||
@ -429,6 +430,7 @@ public class UserRepository : IUserRepository
|
||||
Created = u.Created,
|
||||
LastActive = u.LastActive,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
AgeRestriction = u.AgeRestriction,
|
||||
Libraries = u.Libraries.Select(l => new LibraryDto
|
||||
{
|
||||
Name = l.Name,
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@ -40,7 +41,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
/// The confirmation token for the user (invite). This will be set to null after the user confirms.
|
||||
/// </summary>
|
||||
public string ConfirmationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest age rating the user has access to. Not applicable for admins
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
|
||||
|
||||
/// <inheritdoc />
|
||||
[ConcurrencyCheck]
|
||||
|
@ -8,6 +8,11 @@ namespace API.Entities.Enums;
|
||||
/// <remarks>Based on ComicInfo.xml v2.1 https://github.com/anansi-project/comicinfo/blob/main/drafts/v2.1/ComicInfo.xsd</remarks>
|
||||
public enum AgeRating
|
||||
{
|
||||
/// <summary>
|
||||
/// This is for Age Restriction for Restricted Profiles
|
||||
/// </summary>
|
||||
[Description("Not Applicable")]
|
||||
NotApplicable = -1,
|
||||
[Description("Unknown")]
|
||||
Unknown = 0,
|
||||
[Description("Rating Pending")]
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.Entities;
|
||||
@ -27,6 +28,12 @@ public class ReadingList : IEntityDate
|
||||
public string CoverImage { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest age rating from all Series within the reading list
|
||||
/// </summary>
|
||||
/// <remarks>Introduced in v0.6</remarks>
|
||||
public AgeRating AgeRating { get; set; } = AgeRating.Unknown;
|
||||
|
||||
public ICollection<ReadingListItem> Items { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
|
23
API/Extensions/QueryableExtensions.cs
Normal file
23
API/Extensions/QueryableExtensions.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
public static IQueryable<Series> RestrictAgainstAgeRestriction(this IQueryable<Series> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(s => rating == AgeRating.NotApplicable || s.Metadata.AgeRating <= rating);
|
||||
}
|
||||
|
||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm => sm.AgeRating <= rating));
|
||||
}
|
||||
|
||||
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(rl => rl.AgeRating <= rating);
|
||||
}
|
||||
}
|
@ -118,4 +118,15 @@ public class AccountService : IAccountService
|
||||
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does the user have Change Restriction permission or admin rights
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> HasChangeRestrictionRole(AppUser user)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ public interface IReadingListService
|
||||
Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto);
|
||||
Task<AppUser?> UserHasReadingListAccess(int readingListId, string username);
|
||||
Task<bool> DeleteReadingList(int readingListId, AppUser user);
|
||||
|
||||
Task CalculateReadingListAgeRating(ReadingList readingList);
|
||||
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
|
||||
ReadingList readingList);
|
||||
}
|
||||
@ -41,7 +41,7 @@ public class ReadingListService : IReadingListService
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes all entries that are fully read from the reading list
|
||||
/// Removes all entries that are fully read from the reading list. This commits
|
||||
/// </summary>
|
||||
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess"/> to be called beforehand</remarks>
|
||||
/// <param name="readingListId">Reading List Id</param>
|
||||
@ -62,10 +62,12 @@ public class ReadingListService : IReadingListService
|
||||
itemIdsToRemove.Contains(r.Id));
|
||||
_unitOfWork.ReadingListRepository.BulkRemove(listItems);
|
||||
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
|
||||
await CalculateReadingListAgeRating(readingList);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return true;
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
return true;
|
||||
return await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@ -97,6 +99,11 @@ public class ReadingListService : IReadingListService
|
||||
return await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a certain reading list item from a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto">Only ReadingListId and ReadingListItemId are used</param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto)
|
||||
{
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
|
||||
@ -109,11 +116,34 @@ public class ReadingListService : IReadingListService
|
||||
index++;
|
||||
}
|
||||
|
||||
await CalculateReadingListAgeRating(readingList);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return true;
|
||||
|
||||
return await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the highest Age Rating from each Reading List Item
|
||||
/// </summary>
|
||||
/// <param name="readingList"></param>
|
||||
public async Task CalculateReadingListAgeRating(ReadingList readingList)
|
||||
{
|
||||
await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the highest Age Rating from each Reading List Item
|
||||
/// </summary>
|
||||
/// <remarks>This method is used when the ReadingList doesn't have items yet</remarks>
|
||||
/// <param name="readingList"></param>
|
||||
/// <param name="seriesIds">The series ids of all the reading list items</param>
|
||||
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
|
||||
{
|
||||
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
|
||||
readingList.AgeRating = ageRating;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the user has access to the reading list to perform actions on it
|
||||
/// </summary>
|
||||
@ -167,16 +197,18 @@ public class ReadingListService : IReadingListService
|
||||
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
|
||||
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds))
|
||||
.OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name))
|
||||
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
||||
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting)
|
||||
.ToList();
|
||||
|
||||
var index = lastOrder + 1;
|
||||
foreach (var chapter in chaptersForSeries)
|
||||
foreach (var chapter in chaptersForSeries.Where(chapter => !existingChapterExists.Contains(chapter.Id)))
|
||||
{
|
||||
if (existingChapterExists.Contains(chapter.Id)) continue;
|
||||
readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
await CalculateReadingListAgeRating(readingList, new []{ seriesId });
|
||||
|
||||
return index > lastOrder + 1;
|
||||
}
|
||||
}
|
||||
|
@ -474,6 +474,14 @@ public class SeriesService : ISeriesService
|
||||
if (!libraryIds.Contains(series.LibraryId))
|
||||
throw new UnauthorizedAccessException("User does not have access to the library this series belongs to");
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
if (user.AgeRestriction != AgeRating.NotApplicable)
|
||||
{
|
||||
var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId);
|
||||
if (seriesMetadata.AgeRating > user.AgeRestriction)
|
||||
throw new UnauthorizedAccessException("User is not allowed to view this series due to age restrictions");
|
||||
}
|
||||
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
|
||||
.OrderBy(v => Tasks.Scanner.Parser.Parser.MinNumberFromRange(v.Name))
|
||||
|
@ -84,7 +84,6 @@ public class ParseScannedFiles
|
||||
if (scanDirectoryByDirectory)
|
||||
{
|
||||
// This is used in library scan, so we should check first for a ignore file and use that here as well
|
||||
// TODO: We need to calculate all folders till library root and see if any kavitaignores
|
||||
var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile);
|
||||
var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile);
|
||||
var directories = _directoryService.GetDirectories(folderPath, matcher).ToList();
|
||||
|
@ -184,16 +184,20 @@ public class Startup
|
||||
var userManager = serviceProvider.GetRequiredService<UserManager<AppUser>>();
|
||||
var themeService = serviceProvider.GetRequiredService<IThemeService>();
|
||||
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
||||
var readingListService = serviceProvider.GetRequiredService<IReadingListService>();
|
||||
|
||||
|
||||
// Only run this if we are upgrading
|
||||
await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager);
|
||||
|
||||
await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService);
|
||||
|
||||
// only needed for v0.5.4 and v0.6.0
|
||||
await MigrateNormalizedEverything.Migrate(unitOfWork, dataContext, logger);
|
||||
|
||||
// v0.6.0
|
||||
await MigrateChangeRestrictionRoles.Migrate(unitOfWork, userManager, logger);
|
||||
await MigrateReadingListAgeRating.Migrate(unitOfWork, dataContext, readingListService, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
installVersion.Value = BuildInfo.Version.ToString();
|
||||
|
1
TestData
Submodule
1
TestData
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f
|
@ -1,4 +1,5 @@
|
||||
import { Library } from './library';
|
||||
import { AgeRating } from './metadata/age-rating';
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
@ -6,7 +7,10 @@ export interface Member {
|
||||
email: string;
|
||||
lastActive: string; // datetime
|
||||
created: string; // datetime
|
||||
//isAdmin: boolean;
|
||||
roles: string[];
|
||||
libraries: Library[];
|
||||
/**
|
||||
* If not applicable, will store a -1
|
||||
*/
|
||||
ageRestriction: AgeRating;
|
||||
}
|
@ -1,4 +1,8 @@
|
||||
export enum AgeRating {
|
||||
/**
|
||||
* This is not a valid state for Series/Chapters, but used for Restricted Profiles
|
||||
*/
|
||||
NotApplicable = -1,
|
||||
Unknown = 0,
|
||||
AdultsOnly = 1,
|
||||
EarlyChildhood = 2,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AgeRating } from './metadata/age-rating';
|
||||
import { Preferences } from './preferences/preferences';
|
||||
|
||||
// This interface is only used for login and storing/retreiving JWT from local storage
|
||||
@ -9,4 +10,5 @@ export interface User {
|
||||
preferences: Preferences;
|
||||
apiKey: string;
|
||||
email: string;
|
||||
ageRestriction: AgeRating;
|
||||
}
|
@ -10,8 +10,16 @@ import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { InviteUserResponse } from '../_models/invite-user-response';
|
||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
import { DeviceService } from './device.service';
|
||||
import { UpdateEmailResponse } from '../_models/email/update-email-response';
|
||||
import { AgeRating } from '../_models/metadata/age-rating';
|
||||
|
||||
export enum Role {
|
||||
Admin = 'Admin',
|
||||
ChangePassword = 'Change Password',
|
||||
Bookmark = 'Bookmark',
|
||||
Download = 'Download',
|
||||
ChangeRestriction = 'Change Restriction'
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -49,19 +57,23 @@ export class AccountService implements OnDestroy {
|
||||
}
|
||||
|
||||
hasAdminRole(user: User) {
|
||||
return user && user.roles.includes('Admin');
|
||||
return user && user.roles.includes(Role.Admin);
|
||||
}
|
||||
|
||||
hasChangePasswordRole(user: User) {
|
||||
return user && user.roles.includes('Change Password');
|
||||
return user && user.roles.includes(Role.ChangePassword);
|
||||
}
|
||||
|
||||
hasChangeAgeRestrictionRole(user: User) {
|
||||
return user && user.roles.includes(Role.ChangeRestriction);
|
||||
}
|
||||
|
||||
hasDownloadRole(user: User) {
|
||||
return user && user.roles.includes('Download');
|
||||
return user && user.roles.includes(Role.Download);
|
||||
}
|
||||
|
||||
hasBookmarkRole(user: User) {
|
||||
return user && user.roles.includes('Bookmark');
|
||||
return user && user.roles.includes(Role.Bookmark);
|
||||
}
|
||||
|
||||
getRoles() {
|
||||
@ -149,7 +161,7 @@ export class AccountService implements OnDestroy {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
|
||||
}
|
||||
|
||||
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>}) {
|
||||
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, ageRestriction: AgeRating}) {
|
||||
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/invite', model);
|
||||
}
|
||||
|
||||
@ -186,7 +198,7 @@ export class AccountService implements OnDestroy {
|
||||
return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'});
|
||||
}
|
||||
|
||||
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number}) {
|
||||
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number, ageRestriction: AgeRating}) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update', model);
|
||||
}
|
||||
|
||||
@ -194,6 +206,10 @@ export class AccountService implements OnDestroy {
|
||||
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
|
||||
}
|
||||
|
||||
updateAgeRestriction(ageRating: AgeRating) {
|
||||
return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating});
|
||||
}
|
||||
|
||||
/**
|
||||
* This will get latest preferences for a user and cache them into user store
|
||||
* @returns
|
||||
|
@ -113,12 +113,4 @@ export class LibraryService {
|
||||
return this.libraryTypes[libraryId];
|
||||
}));
|
||||
}
|
||||
|
||||
search(term: string) {
|
||||
if (term === '') {
|
||||
return of(new SearchResultGroup());
|
||||
}
|
||||
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
|
||||
}
|
||||
|
||||
}
|
||||
|
31
UI/Web/src/app/_services/search.service.ts
Normal file
31
UI/Web/src/app/_services/search.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
import { Series } from '../_models/series';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
search(term: string) {
|
||||
if (term === '') {
|
||||
return of(new SearchResultGroup());
|
||||
}
|
||||
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term));
|
||||
}
|
||||
|
||||
getSeriesForMangaFile(mangaFileId: number) {
|
||||
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-mangafile?mangaFileId=' + mangaFileId);
|
||||
}
|
||||
|
||||
getSeriesForChapter(chapterId: number) {
|
||||
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-chapter?chapterId=' + chapterId);
|
||||
}
|
||||
}
|
@ -78,14 +78,6 @@ export class SeriesService {
|
||||
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getSeriesForMangaFile(mangaFileId: number) {
|
||||
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId);
|
||||
}
|
||||
|
||||
getSeriesForChapter(chapterId: number) {
|
||||
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
delete(seriesId: number) {
|
||||
return this.httpClient.delete<boolean>(this.baseUrl + 'series/' + seriesId);
|
||||
}
|
||||
|
@ -1,58 +1,66 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
<form [formGroup]="userForm">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||
This must be a valid email address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@ import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
// TODO: Rename this to EditUserModal
|
||||
@ -17,6 +18,7 @@ export class EditUserComponent implements OnInit {
|
||||
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
selectedRating: AgeRating = AgeRating.NotApplicable;
|
||||
isSaving: boolean = false;
|
||||
|
||||
userForm: FormGroup = new FormGroup({});
|
||||
@ -24,6 +26,7 @@ export class EditUserComponent implements OnInit {
|
||||
public get email() { return this.userForm.get('email'); }
|
||||
public get username() { return this.userForm.get('username'); }
|
||||
public get password() { return this.userForm.get('password'); }
|
||||
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
|
||||
|
||||
@ -38,6 +41,10 @@ export class EditUserComponent implements OnInit {
|
||||
this.selectedRoles = roles;
|
||||
}
|
||||
|
||||
updateRestrictionSelection(rating: AgeRating) {
|
||||
this.selectedRating = rating;
|
||||
}
|
||||
|
||||
updateLibrarySelection(libraries: Array<Library>) {
|
||||
this.selectedLibraries = libraries.map(l => l.id);
|
||||
}
|
||||
@ -51,6 +58,8 @@ export class EditUserComponent implements OnInit {
|
||||
model.userId = this.member.id;
|
||||
model.roles = this.selectedRoles;
|
||||
model.libraries = this.selectedLibraries;
|
||||
model.ageRestriction = this.selectedRating || AgeRating.NotApplicable;
|
||||
console.log('rating: ', this.selectedRating);
|
||||
this.accountService.update(model).subscribe(() => {
|
||||
this.modal.close(true);
|
||||
});
|
||||
|
@ -1,55 +1,61 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p>
|
||||
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
|
||||
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
|
||||
</p>
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
|
||||
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
This field is required
|
||||
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="emailLink !== ''">
|
||||
<h4>User invited</h4>
|
||||
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
|
||||
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
|
||||
</p>
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
|
||||
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="emailLink !== ''">
|
||||
<h4>User invited</h4>
|
||||
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
|
||||
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
|
||||
</p>
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
|
||||
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@ -2,11 +2,10 @@ import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { InviteUserResponse } from 'src/app/_models/invite-user-response';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite-user',
|
||||
@ -22,14 +21,16 @@ export class InviteUserComponent implements OnInit {
|
||||
inviteForm: FormGroup = new FormGroup({});
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
selectedRating: AgeRating = AgeRating.NotApplicable;
|
||||
emailLink: string = '';
|
||||
|
||||
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
|
||||
|
||||
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
|
||||
|
||||
public get email() { return this.inviteForm.get('email'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
|
||||
private confirmService: ConfirmService, private toastr: ToastrService) { }
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
|
||||
@ -47,6 +48,7 @@ export class InviteUserComponent implements OnInit {
|
||||
email,
|
||||
libraries: this.selectedLibraries,
|
||||
roles: this.selectedRoles,
|
||||
ageRestriction: this.selectedRating
|
||||
}).subscribe((data: InviteUserResponse) => {
|
||||
this.emailLink = data.emailLink;
|
||||
this.isSending = false;
|
||||
@ -67,4 +69,8 @@ export class InviteUserComponent implements OnInit {
|
||||
this.selectedLibraries = libraries.map(l => l.id);
|
||||
}
|
||||
|
||||
updateRestrictionSelection(rating: AgeRating) {
|
||||
this.selectedRating = rating;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
|
||||
@ -11,7 +12,10 @@ import { MemberService } from 'src/app/_services/member.service';
|
||||
})
|
||||
export class RoleSelectorComponent implements OnInit {
|
||||
|
||||
@Input() member: Member | undefined;
|
||||
/**
|
||||
* This must have roles
|
||||
*/
|
||||
@Input() member: Member | undefined | User;
|
||||
/**
|
||||
* Allows the selection of Admin role
|
||||
*/
|
||||
@ -25,7 +29,7 @@ export class RoleSelectorComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.getRoles().subscribe(roles => {
|
||||
let bannedRoles = ['Pleb'];
|
||||
const bannedRoles = ['Pleb'];
|
||||
if (!this.allowAdmin) {
|
||||
bannedRoles.push('Admin');
|
||||
}
|
||||
|
@ -1,8 +1,3 @@
|
||||
.scrollable-modal {
|
||||
max-height: calc(var(--vh) * 100 - 198px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lock-active {
|
||||
> .input-group-text {
|
||||
background-color: var(--primary-color);
|
||||
|
@ -8,6 +8,7 @@ import { Series } from 'src/app/_models/series';
|
||||
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SearchService } from 'src/app/_services/search.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
interface RelationControl {
|
||||
@ -47,7 +48,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
|
||||
private onDestroy: Subject<void> = new Subject<void>();
|
||||
|
||||
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
|
||||
public imageService: ImageService, private libraryService: LibraryService,
|
||||
public imageService: ImageService, private libraryService: LibraryService, private searchService: SearchService,
|
||||
private readonly cdRef: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -127,7 +128,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
|
||||
seriesSettings.id = 'relation--' + index;
|
||||
seriesSettings.unique = true;
|
||||
seriesSettings.addIfNonExisting = false;
|
||||
seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe(
|
||||
seriesSettings.fetchFn = (searchFilter: string) => this.searchService.search(searchFilter).pipe(
|
||||
map(group => group.series),
|
||||
map(items => seriesSettings.compareFn(items, searchFilter)),
|
||||
map(series => series.filter(s => s.seriesId !== this.series.id)),
|
||||
@ -142,7 +143,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (series !== undefined) {
|
||||
return this.libraryService.search(series.name).pipe(
|
||||
return this.searchService.search(series.name).pipe(
|
||||
map(group => group.series), map(results => {
|
||||
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
|
||||
return seriesSettings;
|
||||
|
@ -6,7 +6,7 @@ import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { MangaFile } from 'src/app/_models/manga-file';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { SearchService } from 'src/app/_services/search.service';
|
||||
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
|
||||
import { CollectionTag } from '../../_models/collection-tag';
|
||||
import { Library } from '../../_models/library';
|
||||
@ -52,8 +52,8 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {
|
||||
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
|
||||
this.scrollElem = this.document.body;
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
this.searchTerm = val.trim();
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
this.searchService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
this.searchResults = results;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
@ -185,7 +185,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
clickFileSearchResult(item: MangaFile) {
|
||||
this.clearSearch();
|
||||
this.seriesService.getSeriesForMangaFile(item.id).subscribe(series => {
|
||||
this.searchService.getSeriesForMangaFile(item.id).subscribe(series => {
|
||||
if (series !== undefined && series !== null) {
|
||||
this.router.navigate(['library', series.libraryId, 'series', series.id]);
|
||||
}
|
||||
@ -194,7 +194,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
clickChapterSearchResult(item: Chapter) {
|
||||
this.clearSearch();
|
||||
this.seriesService.getSeriesForChapter(item.id).subscribe(series => {
|
||||
this.searchService.getSeriesForChapter(item.id).subscribe(series => {
|
||||
if (series !== undefined && series !== null) {
|
||||
this.router.navigate(['library', series.libraryId, 'series', series.id]);
|
||||
}
|
||||
|
@ -11,7 +11,9 @@ export class AgeRatingPipe implements PipeTransform {
|
||||
|
||||
constructor(private metadataService: MetadataService) {}
|
||||
|
||||
transform(value: AgeRating | AgeRatingDto): Observable<string> {
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): Observable<string> {
|
||||
if (value === undefined || value === null) return of('undefined');
|
||||
|
||||
if (value.hasOwnProperty('title')) {
|
||||
return of((value as AgeRatingDto).title);
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core';
|
||||
export class DefaultValuePipe implements PipeTransform {
|
||||
|
||||
transform(value: any, replacementString = '—'): string {
|
||||
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return replacementString;
|
||||
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN) return replacementString;
|
||||
return value;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,32 @@
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="row mb-2">
|
||||
<div class="col-11"><h4 id="age-restriction">Age Restriction</h4></div>
|
||||
<div class="col-1">
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangeAgeRestrictionAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="isViewMode">
|
||||
<span >{{user?.ageRestriction | ageRating | async}}</span>
|
||||
</ng-container>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||
<ng-container *ngIf="user">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [showContext]="false" [member]="user" [reset]="reset"></app-restriction-selector>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="age-restriction" (click)="resetForm()">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="age-restriction" (click)="saveForm()">Save</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
@ -0,0 +1,79 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable, of, Subject, takeUntil, shareReplay, map, take } from 'rxjs';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-age-restriction',
|
||||
templateUrl: './change-age-restriction.component.html',
|
||||
styleUrls: ['./change-age-restriction.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChangeAgeRestrictionComponent implements OnInit {
|
||||
|
||||
user: User | undefined = undefined;
|
||||
hasChangeAgeRestrictionAbility: Observable<boolean> = of(false);
|
||||
isViewMode: boolean = true;
|
||||
selectedRating: AgeRating = AgeRating.NotApplicable;
|
||||
originalRating!: AgeRating;
|
||||
reset: EventEmitter<AgeRating> = new EventEmitter();
|
||||
|
||||
get AgeRating() { return AgeRating; }
|
||||
|
||||
private onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
|
||||
this.user = user;
|
||||
this.originalRating = this.user?.ageRestriction || AgeRating.NotApplicable;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.hasChangeAgeRestrictionAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
|
||||
return user !== undefined && (!this.accountService.hasAdminRole(user) && this.accountService.hasChangeAgeRestrictionRole(user));
|
||||
}));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateRestrictionSelection(rating: AgeRating) {
|
||||
this.selectedRating = rating;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
if (!this.user) return;
|
||||
console.log('resetting to ', this.originalRating)
|
||||
this.reset.emit(this.originalRating);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
saveForm() {
|
||||
if (this.user === undefined) { return; }
|
||||
|
||||
this.accountService.updateAgeRestriction(this.selectedRating).subscribe(() => {
|
||||
this.toastr.success('Age Restriction has been updated');
|
||||
this.originalRating = this.selectedRating;
|
||||
if (this.user) {
|
||||
this.user.ageRestriction = this.selectedRating;
|
||||
}
|
||||
this.resetForm();
|
||||
this.isViewMode = true;
|
||||
}, err => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
this.isViewMode = !this.isViewMode;
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||
import { map, Observable, of, shareReplay, Subject, take, takeUntil } from 'rxjs';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
@ -29,6 +29,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
|
||||
this.user = user;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
|
||||
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
|
||||
}));
|
||||
@ -38,8 +44,6 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
|
||||
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
|
||||
|
||||
|
||||
|
||||
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
|
||||
const values = this.passwordChangeForm.value;
|
||||
this.passwordsMatch = values.password === values.confirmPassword;
|
||||
|
@ -0,0 +1,19 @@
|
||||
<ng-container *ngIf="restrictionForm">
|
||||
<ng-container *ngIf="showContext">
|
||||
<h4>Age Rating Restriction</h4>
|
||||
<p>When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.
|
||||
<ng-container *ngIf="isAdmin">This is not applicable for admins.</ng-container>
|
||||
</p>
|
||||
</ng-container>
|
||||
<form [formGroup]="restrictionForm">
|
||||
<div class="mb-3">
|
||||
<label for="age-rating" class="form-label visually-hidden">Age Rating</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select"id="age-rating" formControlName="ageRating">
|
||||
<option value="-1">No Restriction</option>
|
||||
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
@ -0,0 +1,68 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-restriction-selector',
|
||||
templateUrl: './restriction-selector.component.html',
|
||||
styleUrls: ['./restriction-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RestrictionSelectorComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input() member: Member | undefined | User;
|
||||
@Input() isAdmin: boolean = false;
|
||||
/**
|
||||
* Show labels and description around the form
|
||||
*/
|
||||
@Input() showContext: boolean = true;
|
||||
@Input() reset: EventEmitter<AgeRating> | undefined;
|
||||
@Output() selected: EventEmitter<AgeRating> = new EventEmitter<AgeRating>();
|
||||
|
||||
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
restrictionForm: FormGroup | undefined;
|
||||
|
||||
constructor(private metadataService: MetadataService, private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.restrictionForm = new FormGroup({
|
||||
'ageRating': new FormControl(this.member?.ageRestriction || AgeRating.NotApplicable, [])
|
||||
});
|
||||
|
||||
if (this.isAdmin) {
|
||||
this.restrictionForm.get('ageRating')?.disable();
|
||||
}
|
||||
|
||||
if (this.reset) {
|
||||
this.reset.subscribe(e => {
|
||||
this.restrictionForm?.get('ageRating')?.setValue(e);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
this.restrictionForm.get('ageRating')?.valueChanges.subscribe(e => {
|
||||
this.selected.emit(parseInt(e, 10));
|
||||
});
|
||||
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings = ratings;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
if (!this.member) return;
|
||||
console.log('changes: ');
|
||||
this.restrictionForm?.get('ageRating')?.setValue(this.member?.ageRestriction || AgeRating.NotApplicable);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.Account">
|
||||
<app-change-email></app-change-email>
|
||||
<app-change-password></app-change-password>
|
||||
<app-change-age-restriction></app-change-age-restriction>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.Prefernces">
|
||||
<p>
|
||||
|
@ -15,6 +15,8 @@ import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
|
||||
import { EditDeviceComponent } from './edit-device/edit-device.component';
|
||||
import { ChangePasswordComponent } from './change-password/change-password.component';
|
||||
import { ChangeEmailComponent } from './change-email/change-email.component';
|
||||
import { ChangeAgeRestrictionComponent } from './change-age-restriction/change-age-restriction.component';
|
||||
import { RestrictionSelectorComponent } from './restriction-selector/restriction-selector.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -28,6 +30,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
|
||||
EditDeviceComponent,
|
||||
ChangePasswordComponent,
|
||||
ChangeEmailComponent,
|
||||
RestrictionSelectorComponent,
|
||||
ChangeAgeRestrictionComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -47,7 +51,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
|
||||
],
|
||||
exports: [
|
||||
SiteThemeProviderPipe,
|
||||
ApiKeyComponent
|
||||
ApiKeyComponent,
|
||||
RestrictionSelectorComponent
|
||||
]
|
||||
})
|
||||
export class UserSettingsModule { }
|
||||
|
@ -6,3 +6,8 @@
|
||||
.modal-title {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.scrollable-modal {
|
||||
max-height: calc(var(--vh) * 100 - 198px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user