From a8144a1d3ed578e9e2f6162e7400391920e1728f Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Tue, 10 Dec 2024 18:49:08 -0600 Subject: [PATCH] Read Only Account Changes + Fixes from last PR (#3453) --- API/Controllers/AccountController.cs | 5 +++- API/Controllers/CBLController.cs | 7 ++++- API/Controllers/ChapterController.cs | 3 +++ API/Controllers/CollectionController.cs | 16 ++++++++++++ API/Controllers/FilterController.cs | 9 ++++++- API/Controllers/PersonController.cs | 2 ++ API/Controllers/ReadingListController.cs | 14 ++++++++++ API/Controllers/StreamController.cs | 18 +++++++++++-- API/Controllers/ThemeController.cs | 4 ++- API/Controllers/UsersController.cs | 9 +++++++ API/Services/StatisticService.cs | 23 ++++++++++++---- UI/Web/.gitignore | 1 + .../app/_directives/dbl-click.directive.ts | 15 ++++++++--- UI/Web/src/app/_services/account.service.ts | 22 +++++++++++++++- UI/Web/src/app/app.component.scss | 1 + .../manga-reader/manga-reader.component.html | 6 ++--- .../manga-reader/manga-reader.component.ts | 9 ++++--- .../side-nav/side-nav.component.html | 4 ++- .../side-nav/side-nav.component.ts | 3 +++ .../preference-nav.component.html | 2 +- .../preference-nav.component.ts | 26 +++++++++++++------ .../change-email/change-email.component.html | 2 +- .../change-password.component.html | 2 +- .../change-password.component.ts | 2 ++ .../manage-devices.component.html | 2 +- .../manage-devices.component.ts | 14 +++++++++- .../theme-manager.component.html | 4 +-- .../theme-manager/theme-manager.component.ts | 6 +++++ 28 files changed, 193 insertions(+), 38 deletions(-) diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 0b47aa526..a05210711 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -457,6 +457,7 @@ public class AccountController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); @@ -494,6 +495,7 @@ public class AccountController : BaseApiController var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized(); if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); @@ -911,7 +913,6 @@ public class AccountController : BaseApiController [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) @@ -1012,6 +1013,8 @@ public class AccountController : BaseApiController await _localizationService.Translate(user.Id, "user-migration-needed")); if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed")); + // TODO: If the target user is read only, we might want to just forgo this + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); user.ConfirmationToken = token; _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index fe274dacb..150628ced 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using API.Constants; using API.DTOs.ReadingLists.CBL; using API.Extensions; using API.Services; @@ -20,11 +21,13 @@ public class CblController : BaseApiController { private readonly IReadingListService _readingListService; private readonly IDirectoryService _directoryService; + private readonly ILocalizationService _localizationService; - public CblController(IReadingListService readingListService, IDirectoryService directoryService) + public CblController(IReadingListService readingListService, IDirectoryService directoryService, ILocalizationService localizationService) { _readingListService = readingListService; _directoryService = directoryService; + _localizationService = localizationService; } /// @@ -91,6 +94,8 @@ public class CblController : BaseApiController [SwaggerIgnore] public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var userId = User.GetUserId(); diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 5bd239086..e98edfb6a 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -58,6 +59,8 @@ public class ChapterController : BaseApiController [HttpDelete] public async Task> DeleteChapter(int chapterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index b49d6aa40..2c0abc609 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -105,6 +105,8 @@ public class CollectionController : BaseApiController [HttpPost("update")] public async Task UpdateTag(AppUserCollectionDto updatedTag) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) @@ -130,6 +132,8 @@ public class CollectionController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleCollections(PromoteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds); var userId = User.GetUserId(); @@ -161,6 +165,8 @@ public class CollectionController : BaseApiController [HttpPost("delete-multiple")] public async Task DeleteMultipleCollections(DeleteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -182,6 +188,8 @@ public class CollectionController : BaseApiController [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Create a new tag and save var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -223,6 +231,8 @@ public class CollectionController : BaseApiController [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series); @@ -247,6 +257,8 @@ public class CollectionController : BaseApiController [HttpDelete] public async Task DeleteTag(int tagId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); @@ -276,6 +288,8 @@ public class CollectionController : BaseApiController [HttpGet("mal-stacks")] public async Task>> GetMalStacksForUser() { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); } @@ -289,6 +303,8 @@ public class CollectionController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Validation check to ensure stack doesn't exist already if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id)) diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index e8cb71117..90772c9aa 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Dashboard; @@ -9,6 +10,7 @@ using API.DTOs.Filtering.v2; using API.Entities; using API.Extensions; using API.Helpers; +using API.Services; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -21,10 +23,12 @@ namespace API.Controllers; public class FilterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public FilterController(IUnitOfWork unitOfWork) + public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService) { _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -37,6 +41,7 @@ public class FilterController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set"); if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase))) @@ -78,6 +83,8 @@ public class FilterController : BaseApiController [HttpDelete] public async Task DeleteFilter(int filterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return Ok(); // This needs to delete any dashboard filters that have it too diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 22a52c04a..bb5ca1aea 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -9,6 +9,7 @@ using API.Services; using API.Services.Tasks.Metadata; using API.SignalR; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nager.ArticleNumber; @@ -72,6 +73,7 @@ public class PersonController : BaseApiController /// /// /// + [Authorize("AdminRequired")] [HttpPost("update")] public async Task> UpdatePerson(UpdatePersonDto dto) { diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 5e84e9f64..25010d636 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -108,6 +108,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-position")] public async Task UpdateListItemPosition(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Make sure UI buffers events var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) @@ -129,6 +130,7 @@ public class ReadingListController : BaseApiController [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -151,6 +153,8 @@ public class ReadingListController : BaseApiController [HttpPost("remove-read")] public async Task DeleteReadFromList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -173,6 +177,7 @@ public class ReadingListController : BaseApiController [HttpDelete] public async Task DeleteList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -193,6 +198,7 @@ public class ReadingListController : BaseApiController [HttpPost("create")] public async Task> CreateList(CreateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists); if (user == null) return Unauthorized(); @@ -216,6 +222,7 @@ public class ReadingListController : BaseApiController [HttpPost("update")] public async Task UpdateList(UpdateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); @@ -245,6 +252,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -287,6 +295,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple")] public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -331,6 +340,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple-series")] public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -369,6 +379,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -405,6 +416,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-chapter")] public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -514,6 +526,8 @@ public class ReadingListController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var userId = User.GetUserId(); if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index a694d5b34..7fb6d6ebb 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Dashboard; using API.DTOs.SideNav; @@ -19,11 +20,13 @@ public class StreamController : BaseApiController { private readonly IStreamService _streamService; private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public StreamController(IStreamService streamService, IUnitOfWork unitOfWork) + public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILocalizationService localizationService) { _streamService = streamService; _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -74,6 +77,7 @@ public class StreamController : BaseApiController [HttpPost("update-external-source")] public async Task> UpdateExternalSource(ExternalSourceDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Check if a host and api key exists for the current user return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto)); } @@ -86,7 +90,8 @@ public class StreamController : BaseApiController [HttpGet("external-source-exists")] public async Task> ExternalSourceExists(string host, string name, string apiKey) { - return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey)); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), name, host, apiKey)); } /// @@ -97,6 +102,7 @@ public class StreamController : BaseApiController [HttpDelete("delete-external-source")] public async Task ExternalSourceExists(int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId); return Ok(); } @@ -110,6 +116,7 @@ public class StreamController : BaseApiController [HttpPost("add-dashboard-stream")] public async Task> AddDashboard([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -121,6 +128,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-stream")] public async Task UpdateDashboardStream(DashboardStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStream(User.GetUserId(), dto); return Ok(); } @@ -133,6 +141,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-position")] public async Task UpdateDashboardStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -146,6 +155,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream")] public async Task> AddSideNav([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -157,6 +167,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream-from-external-source")] public async Task> AddSideNavFromExternalSource([FromQuery] int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId)); } @@ -168,6 +179,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-stream")] public async Task UpdateSideNavStream(SideNavStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStream(User.GetUserId(), dto); return Ok(); } @@ -180,6 +192,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-position")] public async Task UpdateSideNavStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -187,6 +200,7 @@ public class StreamController : BaseApiController [HttpPost("bulk-sidenav-stream-visibility")] public async Task BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto); return Ok(); } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index fb9371919..9e4cee20c 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -103,7 +103,7 @@ public class ThemeController : BaseApiController [HttpDelete] public async Task>> DeleteTheme(int themeId) { - + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _themeService.DeleteTheme(themeId); return Ok(); @@ -128,6 +128,8 @@ public class ThemeController : BaseApiController [HttpPost("upload-theme")] public async Task> DownloadTheme(IFormFile formFile) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file"); if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); var tempFile = await UploadToTemp(formFile); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 26039d700..7639053ba 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -82,12 +83,20 @@ public class UsersController : BaseApiController return Ok(libs.Any(x => x.Id == libraryId)); } + /// + /// Update the user preferences + /// + /// If the user has ReadOnly role, they will not be able to perform this action + /// + /// [HttpPost("update-preferences")] public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.UserPreferences); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var existingPreferences = user!.UserPreferences; existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 8fa22cc2f..cd9c0d4e7 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -89,7 +89,9 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MaxAsync(p => p.LastModified); + .Select(p => p.LastModified) + .DefaultIfEmpty() + .MaxAsync(); // First get the total pages per library @@ -127,12 +129,23 @@ public class StatisticService : IStatisticService var earliestReadDate = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MinAsync(p => p.Created); + .Select(p => p.Created) + .DefaultIfEmpty() + .MinAsync(); + + if (earliestReadDate == DateTime.MinValue) + { + averageReadingTimePerWeek = 0; + } + else + { + var timeDifference = DateTime.Now - earliestReadDate; + var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); + + averageReadingTimePerWeek /= deltaWeeks; + } - var timeDifference = DateTime.Now - earliestReadDate; - var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); - averageReadingTimePerWeek /= deltaWeeks; return new UserReadStatistics() diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 73b400165..8132126c9 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -2,3 +2,4 @@ node_modules/ test-results/ playwright-report/ i18n-cache-busting.json +e2e-tests/environments/environment.local.ts diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts index 98fabf843..ab1d0bcde 100644 --- a/UI/Web/src/app/_directives/dbl-click.directive.ts +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -6,21 +6,30 @@ import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; }) export class DblClickDirective { + @Output() singleClick = new EventEmitter(); @Output() doubleClick = new EventEmitter(); private lastTapTime = 0; private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) + private singleClickTimeout: any; @HostListener('click', ['$event']) handleClick(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - const currentTime = new Date().getTime(); + if (currentTime - this.lastTapTime < this.tapTimeout) { // Detected a double click/tap + clearTimeout(this.singleClickTimeout); // Prevent single-click emission + event.stopPropagation(); + event.preventDefault(); this.doubleClick.emit(event); + } else { + // Delay single-click emission to check if a double-click occurs + this.singleClickTimeout = setTimeout(() => { + this.singleClick.emit(event); // Optional: emit single-click if no double-click follows + }, this.tapTimeout); } + this.lastTapTime = currentTime; } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 54b54931f..623002ada 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -15,6 +15,7 @@ import { AgeRestriction } from '../_models/metadata/age-restriction'; import { TextResonse } from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; +import {CoverImageSize} from "../admin/_models/cover-image-size"; export enum Role { Admin = 'Admin', @@ -27,6 +28,17 @@ export enum Role { Promote = 'Promote', } +export const allRoles = [ + Role.Admin, + Role.ChangePassword, + Role.Bookmark, + Role.Download, + Role.ChangeRestriction, + Role.ReadOnly, + Role.Login, + Role.Promote, +] + @Injectable({ providedIn: 'root' }) @@ -91,14 +103,22 @@ export class AccountService { return true; } - hasAnyRole(user: User, roles: Array) { + hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + + // If restricted roles are provided and the user has any of them, deny access + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return false; + } + + // If roles are empty, allow access (no restrictions by roles) if (roles.length === 0) { return true; } + // Allow access if the user has any of the allowed roles return roles.some(role => user.roles.includes(role)); } diff --git a/UI/Web/src/app/app.component.scss b/UI/Web/src/app/app.component.scss index e5061412b..326f5c603 100644 --- a/UI/Web/src/app/app.component.scss +++ b/UI/Web/src/app/app.component.scss @@ -123,6 +123,7 @@ filter: blur(20px); object-fit: contain; transform: scale(1.1); + mix-blend-mode: color; .background-area { position: absolute; diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 8fe1f6833..733bdd28e 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -55,7 +55,7 @@ [ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea> @if (readerMode !== ReaderMode.Webtoon) { -
+
-
+
} @else { @if (!isLoading && !inSetup) { -
+
{ - if (event.detail > 1) return; - this.toggleMenu(); - }); + // fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => { + // if (event.detail > 1) return; + // this.toggleMenu(); + // }); fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0; @@ -1663,6 +1663,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); } if (this.bookmarkMode) return; + if (!(this.accountService.hasBookmarkRole(this.user) || this.accountService.hasAdminRole(this.user))) return; const pageNum = this.pageNum; // if canvasRenderer and doubleRenderer is undefined, then we are in webtoon mode diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 684e732a1..63aee806f 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -11,7 +11,9 @@ @if (navStreams$ | async; as streams) { @if (showAll) { - + @if (!isReadOnly) { + + } @if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false) {
diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 4428eb41b..37c9f5a54 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -66,6 +66,7 @@ export class SideNavComponent implements OnInit { } showAll: boolean = false; totalSize = 0; + isReadOnly = false; private showAllSubject = new BehaviorSubject(false); showAll$ = this.showAllSubject.asObservable(); @@ -146,6 +147,8 @@ export class SideNavComponent implements OnInit { ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (!user) return; + this.isReadOnly = this.accountService.hasReadOnlyRole(user!); + this.cdRef.markForCheck(); this.loadDataSubject.next(); }); } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html index f88d1471a..f098e8bd7 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html @@ -11,7 +11,7 @@ @if (hasAnyChildren(user, section)) {
{{t(section.title)}}
@for(item of section.children; track item.fragment) { - @if (accountService.hasAnyRole(user, item.roles)) { + @if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) { } } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 738178931..18b5c9659 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -2,7 +2,7 @@ import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, De import {TranslocoDirective} from "@jsverse/transloco"; import {AsyncPipe, DOCUMENT, NgClass} from "@angular/common"; import {NavService} from "../../_services/nav.service"; -import {AccountService, Role} from "../../_services/account.service"; +import {AccountService, allRoles, Role} from "../../_services/account.service"; import {SideNavItemComponent} from "../_components/side-nav-item/side-nav-item.component"; import {ActivatedRoute, NavigationEnd, Router, RouterLink} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -51,11 +51,16 @@ interface PrefSection { class SideNavItem { fragment: SettingsTabId; roles: Array = []; + /** + * If you have any of these, the item will be restricted + */ + restrictRoles: Array = []; badgeCount$?: Observable | undefined; - constructor(fragment: SettingsTabId, roles: Array = [], badgeCount$: Observable | undefined = undefined) { + constructor(fragment: SettingsTabId, roles: Array = [], badgeCount$: Observable | undefined = undefined, restrictRoles: Array = []) { this.fragment = fragment; this.roles = roles; + this.restrictRoles = restrictRoles; this.badgeCount$ = badgeCount$; } } @@ -68,7 +73,6 @@ class SideNavItem { NgClass, AsyncPipe, SideNavItemComponent, - RouterLink, SettingFragmentPipe ], templateUrl: './preference-nav.component.html', @@ -98,7 +102,7 @@ export class PreferenceNavComponent implements AfterViewInit { children: [ new SideNavItem(SettingsTabId.Account, []), new SideNavItem(SettingsTabId.Preferences), - new SideNavItem(SettingsTabId.Customize), + new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), new SideNavItem(SettingsTabId.Clients), new SideNavItem(SettingsTabId.Theme), new SideNavItem(SettingsTabId.Devices), @@ -119,7 +123,7 @@ export class PreferenceNavComponent implements AfterViewInit { { title: 'import-section-title', children: [ - new SideNavItem(SettingsTabId.CBLImport, []), + new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]), ] }, { @@ -175,7 +179,6 @@ export class PreferenceNavComponent implements AfterViewInit { this.navService.collapseSideNav(true); } - this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { if (res) { this.hasActiveLicense = true; @@ -203,7 +206,6 @@ export class PreferenceNavComponent implements AfterViewInit { if (this.sections[2].children.length === 1) { this.sections[2].children.push(new SideNavItem(SettingsTabId.MALStackImport, [])); } - } this.scrollToActiveItem(); @@ -227,7 +229,15 @@ export class PreferenceNavComponent implements AfterViewInit { } hasAnyChildren(user: User, section: PrefSection) { - return section.children.filter(item => this.accountService.hasAnyRole(user, item.roles)).length > 0; + // Filter out items where the user has a restricted role + const visibleItems = section.children.filter(item => + item.restrictRoles.length === 0 || !this.accountService.hasAnyRole(user, item.restrictRoles) + ); + + // Check if the user has any allowed roles in the remaining items + return visibleItems.some(item => + this.accountService.hasAnyRole(user, item.roles) + ); } collapse() { diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.html b/UI/Web/src/app/user-settings/change-email/change-email.component.html index 2e2be81ee..fd1779b2a 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.html +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.html @@ -1,6 +1,6 @@ - + @if(emailConfirmed) { diff --git a/UI/Web/src/app/user-settings/change-password/change-password.component.html b/UI/Web/src/app/user-settings/change-password/change-password.component.html index 751a09c2d..34abd45ad 100644 --- a/UI/Web/src/app/user-settings/change-password/change-password.component.html +++ b/UI/Web/src/app/user-settings/change-password/change-password.component.html @@ -1,5 +1,5 @@ - + *************** diff --git a/UI/Web/src/app/user-settings/change-password/change-password.component.ts b/UI/Web/src/app/user-settings/change-password/change-password.component.ts index a7dd6fab9..193055acd 100644 --- a/UI/Web/src/app/user-settings/change-password/change-password.component.ts +++ b/UI/Web/src/app/user-settings/change-password/change-password.component.ts @@ -41,6 +41,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { passwordsMatch = false; resetPasswordErrors: string[] = []; isViewMode: boolean = true; + canEdit: boolean = false; public get password() { return this.passwordChangeForm.get('password'); } @@ -50,6 +51,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => { this.user = user; + this.canEdit = !this.accountService.hasReadOnlyRole(user!); this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html index 30a828c79..70878d409 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html @@ -1,7 +1,7 @@
-
diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts index 582453427..5659b26c2 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, + Component, DestroyRef, inject, OnInit } from '@angular/core'; @@ -20,6 +20,10 @@ import {SortableHeader} from "../../_single-module/table/_directives/sortable-he import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {EditDeviceModalComponent} from "../_modals/edit-device-modal/edit-device-modal.component"; import {DefaultModalOptions} from "../../_models/default-modal-options"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {map} from "rxjs"; +import {shareReplay} from "rxjs/operators"; +import {AccountService} from "../../_services/account.service"; @Component({ selector: 'app-manage-devices', @@ -33,16 +37,24 @@ import {DefaultModalOptions} from "../../_models/default-modal-options"; export class ManageDevicesComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); private readonly deviceService = inject(DeviceService); private readonly settingsService = inject(SettingsService); private readonly confirmService = inject(ConfirmService); private readonly modalService = inject(NgbModal); + private readonly accountService = inject(AccountService); devices: Array = []; isEditingDevice: boolean = false; device: Device | undefined; hasEmailSetup = false; + isReadOnly$ = this.accountService.currentUser$.pipe( + takeUntilDestroyed(this.destroyRef), + map(c => c && this.accountService.hasReadOnlyRole(c)), + shareReplay({refCount: true, bufferSize: 1}), + ); + ngOnInit(): void { this.settingsService.isEmailSetup().subscribe(res => { this.hasEmailSetup = res; diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html index 9e74e887c..7d3bfdf68 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html @@ -87,14 +87,14 @@ {{selectedTheme.name | sentenceCase}}
@if (selectedTheme.isSiteTheme) { - @if (selectedTheme.name !== 'Dark') { + @if (selectedTheme.name !== 'Dark' && (canUseThemes$ | async)) { } @if (hasAdmin$ | async) { } - } @else { + } @else if (canUseThemes$ | async) { }
diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts index 561068404..2ba7c670d 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts @@ -73,6 +73,12 @@ export class ThemeManagerComponent { shareReplay({refCount: true, bufferSize: 1}), ); + canUseThemes$ = this.accountService.currentUser$.pipe( + takeUntilDestroyed(this.destroyRef), + map(c => c && !this.accountService.hasReadOnlyRole(c)), + shareReplay({refCount: true, bufferSize: 1}), + ); + files: NgxFileDropEntry[] = []; acceptableExtensions = ['.css'].join(','); isUploadingTheme: boolean = false;